detached 1905b39

feat(sandbox): host-wide lock, ErrLeaked/ErrBusy, boot gate, ID and Isolation, free-space warning, new limits in config

commit
1905b3977824ac48adbbf3a0fb0b8517677dd7f3
author
brice · · 2026-10-12 16:10 UTC
parent
8784b11
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +447 -12
M internal/sandbox/config.go +60 -0
@@ -29,6 +29,13 @@ const (
2929 EnvJailer = "ERMITE_RUNNER_JAILER"
3030 EnvJailerMode = "ERMITE_RUNNER_JAILER_MODE"
3131 EnvKernel = "ERMITE_RUNNER_KERNEL"
32+
33+ EnvJobPids = "ERMITE_RUNNER_JOB_PIDS"
34+ EnvDenyPrivileged = "ERMITE_RUNNER_DENY_PRIVILEGED"
35+ EnvLockFile = "ERMITE_RUNNER_LOCK_FILE"
36+ EnvJobDiskBW = "ERMITE_RUNNER_JOB_DISK_BANDWIDTH"
37+ EnvJobDiskIOPS = "ERMITE_RUNNER_JOB_DISK_IOPS"
38+ EnvJobNetBandwidth = "ERMITE_RUNNER_JOB_NET_BANDWIDTH"
3239 )
3340
3441 // Defaults.
@@ -40,6 +47,17 @@ const (
4047 DefaultCPUs = 2
4148 DefaultMemoryMiB = 4096
4249 DefaultDiskMiB = 64 * 1024
50+ DefaultJobPids = 32768
51+ // DefaultLockFile is the single-instance lock of the host: reaping removes
52+ // everything the runner labels as its own, so two runners (even with
53+ // different state directories, or one in a container) must never coexist.
54+ DefaultLockFile = "/run/ermite-runner.lock"
55+ // Rate limits of a micro-VM, generous so that they only stop a runaway job
56+ // from starving the host: 400 MiB/s and 20000 IOPS per disk, 1 Gbit/s per
57+ // network direction. Zero disables a limit.
58+ DefaultDiskBandwidthMiB = 400
59+ DefaultDiskIOPS = 20000
60+ DefaultNetMbit = 1000
4361 )
4462
4563 // Config is the sandbox part of the runner configuration.
@@ -81,6 +99,17 @@ type Config struct {
8199 // DNSPort is the port of the DNS forwarder; 53 in production. Tests that
82100 // cannot bind it use another port with no guest.
83101 DNSPort int
102+
103+ // JobPids is the process limit of a job (container PidsLimit, VM pids.max
104+ // is derived from the vCPU count and is not affected).
105+ JobPids int
106+ // DenyPrivileged refuses the container mode when it would have to run
107+ // privileged (no sysbox).
108+ DenyPrivileged bool
109+ // LockFile is the host-wide single-instance lock.
110+ LockFile string
111+ // Rate limits of micro-VMs; zero disables.
112+ DiskBandwidthMiB, DiskIOPS, NetMbit int
84113 }
85114
86115 // LoadConfig reads the sandbox settings from getenv (os.Getenv in production).
@@ -95,6 +124,12 @@ func LoadConfig(getenv func(string) string) (Config, error) {
95124 UIDBase: DefaultUIDBase,
96125 KVMPath: "/dev/kvm",
97126 DNSPort: 53,
127+ JobPids: DefaultJobPids,
128+ LockFile: DefaultLockFile,
129+
130+ DiskBandwidthMiB: DefaultDiskBandwidthMiB,
131+ DiskIOPS: DefaultDiskIOPS,
132+ NetMbit: DefaultNetMbit,
98133 }
99134 var err error
100135 if v := getenv(EnvIsolation); v != "" {
@@ -144,6 +179,31 @@ func LoadConfig(getenv func(string) string) (Config, error) {
144179 if 1<<(30-c.Network.Bits()) < c.Capacity {
145180 return c, fmt.Errorf("%s: %s has fewer /30 subnets than the capacity %d", EnvNetwork, c.Network, c.Capacity)
146181 }
182+ if c.JobPids, err = intEnv(getenv, EnvJobPids, c.JobPids, 64, 4_194_304); err != nil {
183+ return c, err
184+ }
185+ switch strings.ToLower(strings.TrimSpace(getenv(EnvDenyPrivileged))) {
186+ case "", "0", "false", "no":
187+ case "1", "true", "yes":
188+ c.DenyPrivileged = true
189+ default:
190+ return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged))
191+ }
192+ if v := getenv(EnvLockFile); v != "" {
193+ if !filepath.IsAbs(v) {
194+ return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v)
195+ }
196+ c.LockFile = v
197+ }
198+ if c.DiskBandwidthMiB, err = intEnv(getenv, EnvJobDiskBW, c.DiskBandwidthMiB, 0, 1_000_000); err != nil {
199+ return c, err
200+ }
201+ if c.DiskIOPS, err = intEnv(getenv, EnvJobDiskIOPS, c.DiskIOPS, 0, 100_000_000); err != nil {
202+ return c, err
203+ }
204+ if c.NetMbit, err = intEnv(getenv, EnvJobNetBandwidth, c.NetMbit, 0, 1_000_000); err != nil {
205+ return c, err
206+ }
147207 c.Firecracker = getenv(EnvFirecracker)
148208 c.Jailer = getenv(EnvJailer)
149209 c.Kernel = getenv(EnvKernel)
@@ -29,6 +29,13 @@ const (
29 EnvJailer = "ERMITE_RUNNER_JAILER"29 EnvJailer = "ERMITE_RUNNER_JAILER"
30 EnvJailerMode = "ERMITE_RUNNER_JAILER_MODE"30 EnvJailerMode = "ERMITE_RUNNER_JAILER_MODE"
31 EnvKernel = "ERMITE_RUNNER_KERNEL"31 EnvKernel = "ERMITE_RUNNER_KERNEL"
32+
33+ EnvJobPids = "ERMITE_RUNNER_JOB_PIDS"
34+ EnvDenyPrivileged = "ERMITE_RUNNER_DENY_PRIVILEGED"
35+ EnvLockFile = "ERMITE_RUNNER_LOCK_FILE"
36+ EnvJobDiskBW = "ERMITE_RUNNER_JOB_DISK_BANDWIDTH"
37+ EnvJobDiskIOPS = "ERMITE_RUNNER_JOB_DISK_IOPS"
38+ EnvJobNetBandwidth = "ERMITE_RUNNER_JOB_NET_BANDWIDTH"
32 )39 )
33 40
34 // Defaults.41 // Defaults.
@@ -40,6 +47,17 @@ const (
40 DefaultCPUs = 247 DefaultCPUs = 2
41 DefaultMemoryMiB = 409648 DefaultMemoryMiB = 4096
42 DefaultDiskMiB = 64 * 102449 DefaultDiskMiB = 64 * 1024
50+ DefaultJobPids = 32768
51+ // DefaultLockFile is the single-instance lock of the host: reaping removes
52+ // everything the runner labels as its own, so two runners (even with
53+ // different state directories, or one in a container) must never coexist.
54+ DefaultLockFile = "/run/ermite-runner.lock"
55+ // Rate limits of a micro-VM, generous so that they only stop a runaway job
56+ // from starving the host: 400 MiB/s and 20000 IOPS per disk, 1 Gbit/s per
57+ // network direction. Zero disables a limit.
58+ DefaultDiskBandwidthMiB = 400
59+ DefaultDiskIOPS = 20000
60+ DefaultNetMbit = 1000
43 )61 )
44 62
45 // Config is the sandbox part of the runner configuration.63 // Config is the sandbox part of the runner configuration.
@@ -81,6 +99,17 @@ type Config struct {
81 // DNSPort is the port of the DNS forwarder; 53 in production. Tests that99 // DNSPort is the port of the DNS forwarder; 53 in production. Tests that
82 // cannot bind it use another port with no guest.100 // cannot bind it use another port with no guest.
83 DNSPort int101 DNSPort int
102+
103+ // JobPids is the process limit of a job (container PidsLimit, VM pids.max
104+ // is derived from the vCPU count and is not affected).
105+ JobPids int
106+ // DenyPrivileged refuses the container mode when it would have to run
107+ // privileged (no sysbox).
108+ DenyPrivileged bool
109+ // LockFile is the host-wide single-instance lock.
110+ LockFile string
111+ // Rate limits of micro-VMs; zero disables.
112+ DiskBandwidthMiB, DiskIOPS, NetMbit int
84 }113 }
85 114
86 // LoadConfig reads the sandbox settings from getenv (os.Getenv in production).115 // LoadConfig reads the sandbox settings from getenv (os.Getenv in production).
@@ -95,6 +124,12 @@ func LoadConfig(getenv func(string) string) (Config, error) {
95 UIDBase: DefaultUIDBase,124 UIDBase: DefaultUIDBase,
96 KVMPath: "/dev/kvm",125 KVMPath: "/dev/kvm",
97 DNSPort: 53,126 DNSPort: 53,
127+ JobPids: DefaultJobPids,
128+ LockFile: DefaultLockFile,
129+
130+ DiskBandwidthMiB: DefaultDiskBandwidthMiB,
131+ DiskIOPS: DefaultDiskIOPS,
132+ NetMbit: DefaultNetMbit,
98 }133 }
99 var err error134 var err error
100 if v := getenv(EnvIsolation); v != "" {135 if v := getenv(EnvIsolation); v != "" {
@@ -144,6 +179,31 @@ func LoadConfig(getenv func(string) string) (Config, error) {
144 if 1<<(30-c.Network.Bits()) < c.Capacity {179 if 1<<(30-c.Network.Bits()) < c.Capacity {
145 return c, fmt.Errorf("%s: %s has fewer /30 subnets than the capacity %d", EnvNetwork, c.Network, c.Capacity)180 return c, fmt.Errorf("%s: %s has fewer /30 subnets than the capacity %d", EnvNetwork, c.Network, c.Capacity)
146 }181 }
182+ if c.JobPids, err = intEnv(getenv, EnvJobPids, c.JobPids, 64, 4_194_304); err != nil {
183+ return c, err
184+ }
185+ switch strings.ToLower(strings.TrimSpace(getenv(EnvDenyPrivileged))) {
186+ case "", "0", "false", "no":
187+ case "1", "true", "yes":
188+ c.DenyPrivileged = true
189+ default:
190+ return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged))
191+ }
192+ if v := getenv(EnvLockFile); v != "" {
193+ if !filepath.IsAbs(v) {
194+ return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v)
195+ }
196+ c.LockFile = v
197+ }
198+ if c.DiskBandwidthMiB, err = intEnv(getenv, EnvJobDiskBW, c.DiskBandwidthMiB, 0, 1_000_000); err != nil {
199+ return c, err
200+ }
201+ if c.DiskIOPS, err = intEnv(getenv, EnvJobDiskIOPS, c.DiskIOPS, 0, 100_000_000); err != nil {
202+ return c, err
203+ }
204+ if c.NetMbit, err = intEnv(getenv, EnvJobNetBandwidth, c.NetMbit, 0, 1_000_000); err != nil {
205+ return c, err
206+ }
147 c.Firecracker = getenv(EnvFirecracker)207 c.Firecracker = getenv(EnvFirecracker)
148 c.Jailer = getenv(EnvJailer)208 c.Jailer = getenv(EnvJailer)
149 c.Kernel = getenv(EnvKernel)209 c.Kernel = getenv(EnvKernel)
A internal/sandbox/core_test.go +161 -0
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+// S3: one runner per host, whatever its state directory.
16+func TestHostLockIsExclusiveAndReleasable(t *testing.T) {
17+ p := filepath.Join(t.TempDir(), "run", "ermite-runner.lock")
18+ a, err := AcquireHostLock(p)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if _, err := AcquireHostLock(p); err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
23+ t.Fatalf("a second runner must be refused with a clear message, got %v", err)
24+ }
25+ if err := a.Release(); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := a.Release(); err != nil {
29+ t.Fatalf("double release: %v", err)
30+ }
31+ b, err := AcquireHostLock(p)
32+ if err != nil {
33+ t.Fatalf("the lock must be free after Release: %v", err)
34+ }
35+ _ = b.Release()
36+ var nilLock *HostLock
37+ if err := nilLock.Release(); err != nil {
38+ t.Fatal(err)
39+ }
40+}
41+
42+func TestHostLockErrorNamesTheOverride(t *testing.T) {
43+ _, err := AcquireHostLock("/proc/nope/ermite.lock")
44+ if err == nil || !strings.Contains(err.Error(), EnvLockFile) {
45+ t.Fatalf("got %v", err)
46+ }
47+}
48+
49+func TestConfigNewSettings(t *testing.T) {
50+ c, err := LoadConfig(env(nil))
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if c.JobPids != 32768 || c.DenyPrivileged || c.LockFile != "/run/ermite-runner.lock" ||
55+ c.DiskBandwidthMiB != 400 || c.DiskIOPS != 20000 || c.NetMbit != 1000 {
56+ t.Fatalf("%+v", c)
57+ }
58+ c, err = LoadConfig(env(map[string]string{
59+ EnvJobPids: "1000", EnvDenyPrivileged: "1", EnvLockFile: "/tmp/x.lock",
60+ EnvJobDiskBW: "0", EnvJobDiskIOPS: "5000", EnvJobNetBandwidth: "100",
61+ }))
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if c.JobPids != 1000 || !c.DenyPrivileged || c.LockFile != "/tmp/x.lock" || c.DiskBandwidthMiB != 0 || c.DiskIOPS != 5000 || c.NetMbit != 100 {
66+ t.Fatalf("%+v", c)
67+ }
68+ for name, m := range map[string]map[string]string{
69+ "pids low": {EnvJobPids: "3"},
70+ "deny": {EnvDenyPrivileged: "maybe"},
71+ "lock rel": {EnvLockFile: "run/x.lock"},
72+ "bw negative": {EnvJobDiskBW: "-1"},
73+ "net not int": {EnvJobNetBandwidth: "fast"},
74+ } {
75+ if _, err := LoadConfig(env(m)); err == nil {
76+ t.Errorf("%s accepted", name)
77+ }
78+ }
79+}
80+
81+// C8: leaked slots stay taken until ReleaseLeaked; Busy ignores them.
82+func TestSlotsLeakedLifecycle(t *testing.T) {
83+ s := NewSlots(3)
84+ a, _ := s.Acquire()
85+ b, _ := s.Acquire()
86+ s.MarkLeaked(a)
87+ if s.InUse() != 2 || s.Busy() != 1 {
88+ t.Fatalf("InUse=%d Busy=%d", s.InUse(), s.Busy())
89+ }
90+ c, _ := s.Acquire()
91+ if c == a || c == b {
92+ t.Fatalf("a leaked slot must not be handed out again: %d", c)
93+ }
94+ s.Release(a) // a normal release also clears the leak flag
95+ if s.InUse() != 2 {
96+ t.Fatal(s.InUse())
97+ }
98+ s.MarkLeaked(b)
99+ s.ReleaseLeaked()
100+ if s.InUse() != 1 || s.Busy() != 1 {
101+ t.Fatalf("after ReleaseLeaked InUse=%d Busy=%d", s.InUse(), s.Busy())
102+ }
103+}
104+
105+func TestErrLeakedIsMatchable(t *testing.T) {
106+ err := errors.Join(errors.New("remove container: busy"), ErrLeaked)
107+ if !errors.Is(err, ErrLeaked) {
108+ t.Fatal("errors.Is must see ErrLeaked through a join")
109+ }
110+}
111+
112+// C7: Abort cancels a boot in progress and forbids a later one.
113+func TestBootGate(t *testing.T) {
114+ var g BootGate
115+ ctx, err := g.Begin(context.Background())
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ go func() { time.Sleep(20 * time.Millisecond); g.Abort() }()
120+ select {
121+ case <-ctx.Done():
122+ if !errors.Is(context.Cause(ctx), ErrAborted) {
123+ t.Fatalf("cause %v", context.Cause(ctx))
124+ }
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("Abort did not cancel the boot context")
127+ }
128+ g.End()
129+ if _, err := g.Begin(context.Background()); !errors.Is(err, ErrAborted) {
130+ t.Fatalf("Begin after Abort: %v", err)
131+ }
132+ // Parent cancellation still works, and End releases the context.
133+ var g2 BootGate
134+ parent, cancel := context.WithCancel(context.Background())
135+ c2, _ := g2.Begin(parent)
136+ cancel()
137+ <-c2.Done()
138+ g2.End()
139+ g2.End()
140+}
141+
142+func TestWarnIfLowSpace(t *testing.T) {
143+ var buf bytes.Buffer
144+ ctx := logCtx(&buf)
145+ WarnIfLowSpace(ctx, "/x", 2, 64*1024, func(string) (int64, error) { return 10 * 1024, nil })
146+ if !strings.Contains(buf.String(), "less free space") || !strings.Contains(buf.String(), `"worst_case_mib":131072`) {
147+ t.Fatalf("expected a warning, got %q", buf.String())
148+ }
149+ buf.Reset()
150+ WarnIfLowSpace(ctx, "/x", 2, 1024, func(string) (int64, error) { return 10 * 1024, nil })
151+ if buf.Len() != 0 {
152+ t.Fatalf("no warning expected: %q", buf.String())
153+ }
154+ WarnIfLowSpace(ctx, "/x", 1, 1, func(string) (int64, error) { return 0, errors.New("statfs failed") })
155+ if !strings.Contains(buf.String(), "cannot check") {
156+ t.Fatalf("a statfs error must be logged: %q", buf.String())
157+ }
158+ if n, err := FreeMiB(t.TempDir()); err != nil || n <= 0 {
159+ t.Fatalf("FreeMiB: %d %v", n, err)
160+ }
161+}
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+// S3: one runner per host, whatever its state directory.
16+func TestHostLockIsExclusiveAndReleasable(t *testing.T) {
17+ p := filepath.Join(t.TempDir(), "run", "ermite-runner.lock")
18+ a, err := AcquireHostLock(p)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if _, err := AcquireHostLock(p); err == nil || !strings.Contains(err.Error(), "another ermite-runner is running on this host") {
23+ t.Fatalf("a second runner must be refused with a clear message, got %v", err)
24+ }
25+ if err := a.Release(); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := a.Release(); err != nil {
29+ t.Fatalf("double release: %v", err)
30+ }
31+ b, err := AcquireHostLock(p)
32+ if err != nil {
33+ t.Fatalf("the lock must be free after Release: %v", err)
34+ }
35+ _ = b.Release()
36+ var nilLock *HostLock
37+ if err := nilLock.Release(); err != nil {
38+ t.Fatal(err)
39+ }
40+}
41+
42+func TestHostLockErrorNamesTheOverride(t *testing.T) {
43+ _, err := AcquireHostLock("/proc/nope/ermite.lock")
44+ if err == nil || !strings.Contains(err.Error(), EnvLockFile) {
45+ t.Fatalf("got %v", err)
46+ }
47+}
48+
49+func TestConfigNewSettings(t *testing.T) {
50+ c, err := LoadConfig(env(nil))
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if c.JobPids != 32768 || c.DenyPrivileged || c.LockFile != "/run/ermite-runner.lock" ||
55+ c.DiskBandwidthMiB != 400 || c.DiskIOPS != 20000 || c.NetMbit != 1000 {
56+ t.Fatalf("%+v", c)
57+ }
58+ c, err = LoadConfig(env(map[string]string{
59+ EnvJobPids: "1000", EnvDenyPrivileged: "1", EnvLockFile: "/tmp/x.lock",
60+ EnvJobDiskBW: "0", EnvJobDiskIOPS: "5000", EnvJobNetBandwidth: "100",
61+ }))
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if c.JobPids != 1000 || !c.DenyPrivileged || c.LockFile != "/tmp/x.lock" || c.DiskBandwidthMiB != 0 || c.DiskIOPS != 5000 || c.NetMbit != 100 {
66+ t.Fatalf("%+v", c)
67+ }
68+ for name, m := range map[string]map[string]string{
69+ "pids low": {EnvJobPids: "3"},
70+ "deny": {EnvDenyPrivileged: "maybe"},
71+ "lock rel": {EnvLockFile: "run/x.lock"},
72+ "bw negative": {EnvJobDiskBW: "-1"},
73+ "net not int": {EnvJobNetBandwidth: "fast"},
74+ } {
75+ if _, err := LoadConfig(env(m)); err == nil {
76+ t.Errorf("%s accepted", name)
77+ }
78+ }
79+}
80+
81+// C8: leaked slots stay taken until ReleaseLeaked; Busy ignores them.
82+func TestSlotsLeakedLifecycle(t *testing.T) {
83+ s := NewSlots(3)
84+ a, _ := s.Acquire()
85+ b, _ := s.Acquire()
86+ s.MarkLeaked(a)
87+ if s.InUse() != 2 || s.Busy() != 1 {
88+ t.Fatalf("InUse=%d Busy=%d", s.InUse(), s.Busy())
89+ }
90+ c, _ := s.Acquire()
91+ if c == a || c == b {
92+ t.Fatalf("a leaked slot must not be handed out again: %d", c)
93+ }
94+ s.Release(a) // a normal release also clears the leak flag
95+ if s.InUse() != 2 {
96+ t.Fatal(s.InUse())
97+ }
98+ s.MarkLeaked(b)
99+ s.ReleaseLeaked()
100+ if s.InUse() != 1 || s.Busy() != 1 {
101+ t.Fatalf("after ReleaseLeaked InUse=%d Busy=%d", s.InUse(), s.Busy())
102+ }
103+}
104+
105+func TestErrLeakedIsMatchable(t *testing.T) {
106+ err := errors.Join(errors.New("remove container: busy"), ErrLeaked)
107+ if !errors.Is(err, ErrLeaked) {
108+ t.Fatal("errors.Is must see ErrLeaked through a join")
109+ }
110+}
111+
112+// C7: Abort cancels a boot in progress and forbids a later one.
113+func TestBootGate(t *testing.T) {
114+ var g BootGate
115+ ctx, err := g.Begin(context.Background())
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ go func() { time.Sleep(20 * time.Millisecond); g.Abort() }()
120+ select {
121+ case <-ctx.Done():
122+ if !errors.Is(context.Cause(ctx), ErrAborted) {
123+ t.Fatalf("cause %v", context.Cause(ctx))
124+ }
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("Abort did not cancel the boot context")
127+ }
128+ g.End()
129+ if _, err := g.Begin(context.Background()); !errors.Is(err, ErrAborted) {
130+ t.Fatalf("Begin after Abort: %v", err)
131+ }
132+ // Parent cancellation still works, and End releases the context.
133+ var g2 BootGate
134+ parent, cancel := context.WithCancel(context.Background())
135+ c2, _ := g2.Begin(parent)
136+ cancel()
137+ <-c2.Done()
138+ g2.End()
139+ g2.End()
140+}
141+
142+func TestWarnIfLowSpace(t *testing.T) {
143+ var buf bytes.Buffer
144+ ctx := logCtx(&buf)
145+ WarnIfLowSpace(ctx, "/x", 2, 64*1024, func(string) (int64, error) { return 10 * 1024, nil })
146+ if !strings.Contains(buf.String(), "less free space") || !strings.Contains(buf.String(), `"worst_case_mib":131072`) {
147+ t.Fatalf("expected a warning, got %q", buf.String())
148+ }
149+ buf.Reset()
150+ WarnIfLowSpace(ctx, "/x", 2, 1024, func(string) (int64, error) { return 10 * 1024, nil })
151+ if buf.Len() != 0 {
152+ t.Fatalf("no warning expected: %q", buf.String())
153+ }
154+ WarnIfLowSpace(ctx, "/x", 1, 1, func(string) (int64, error) { return 0, errors.New("statfs failed") })
155+ if !strings.Contains(buf.String(), "cannot check") {
156+ t.Fatalf("a statfs error must be logged: %q", buf.String())
157+ }
158+ if n, err := FreeMiB(t.TempDir()); err != nil || n <= 0 {
159+ t.Fatalf("FreeMiB: %d %v", n, err)
160+ }
161+}
A internal/sandbox/gate.go +55 -0
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+)
10+
11+// BootGate lets Stop abort a Start in progress. Start holds the sandbox's own
12+// mutex for the whole boot (up to the boot timeout), so Stop could otherwise
13+// only wait for it; with a gate, Stop first calls Abort, which cancels the
14+// context Start runs its boot with, and then takes the mutex.
15+type BootGate struct {
16+ mu sync.Mutex
17+ cancel context.CancelFunc
18+ aborted bool
19+}
20+
21+// ErrAborted is the cause of a boot cancelled by Stop.
22+var ErrAborted = errors.New("sandbox: start aborted by Stop")
23+
24+// Begin returns the context to boot with: parent, cancelled by Abort. It fails
25+// when Abort was already called (Stop before Start).
26+func (g *BootGate) Begin(parent context.Context) (context.Context, error) {
27+ g.mu.Lock()
28+ defer g.mu.Unlock()
29+ if g.aborted {
30+ return nil, ErrAborted
31+ }
32+ ctx, cancel := context.WithCancelCause(parent)
33+ g.cancel = func() { cancel(ErrAborted) }
34+ return ctx, nil
35+}
36+
37+// End must be called when the boot finished, successfully or not.
38+func (g *BootGate) End() {
39+ g.mu.Lock()
40+ defer g.mu.Unlock()
41+ if g.cancel != nil {
42+ g.cancel()
43+ g.cancel = nil
44+ }
45+}
46+
47+// Abort cancels a boot in progress and forbids a later Begin. It never blocks.
48+func (g *BootGate) Abort() {
49+ g.mu.Lock()
50+ defer g.mu.Unlock()
51+ g.aborted = true
52+ if g.cancel != nil {
53+ g.cancel()
54+ }
55+}
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+)
10+
11+// BootGate lets Stop abort a Start in progress. Start holds the sandbox's own
12+// mutex for the whole boot (up to the boot timeout), so Stop could otherwise
13+// only wait for it; with a gate, Stop first calls Abort, which cancels the
14+// context Start runs its boot with, and then takes the mutex.
15+type BootGate struct {
16+ mu sync.Mutex
17+ cancel context.CancelFunc
18+ aborted bool
19+}
20+
21+// ErrAborted is the cause of a boot cancelled by Stop.
22+var ErrAborted = errors.New("sandbox: start aborted by Stop")
23+
24+// Begin returns the context to boot with: parent, cancelled by Abort. It fails
25+// when Abort was already called (Stop before Start).
26+func (g *BootGate) Begin(parent context.Context) (context.Context, error) {
27+ g.mu.Lock()
28+ defer g.mu.Unlock()
29+ if g.aborted {
30+ return nil, ErrAborted
31+ }
32+ ctx, cancel := context.WithCancelCause(parent)
33+ g.cancel = func() { cancel(ErrAborted) }
34+ return ctx, nil
35+}
36+
37+// End must be called when the boot finished, successfully or not.
38+func (g *BootGate) End() {
39+ g.mu.Lock()
40+ defer g.mu.Unlock()
41+ if g.cancel != nil {
42+ g.cancel()
43+ g.cancel = nil
44+ }
45+}
46+
47+// Abort cancels a boot in progress and forbids a later Begin. It never blocks.
48+func (g *BootGate) Abort() {
49+ g.mu.Lock()
50+ defer g.mu.Unlock()
51+ g.aborted = true
52+ if g.cancel != nil {
53+ g.cancel()
54+ }
55+}
A internal/sandbox/lock.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// HostLock is the host-wide single-instance lock. Reaping removes every process,
14+// device, firewall rule and Docker object labelled as the runner's own, so it is
15+// only safe when no other runner of the same host is alive: the lock lives at a
16+// fixed path of the host (DefaultLockFile), not in a state directory, which two
17+// runners could choose differently, and it is taken by both isolation modes.
18+type HostLock struct{ f *os.File }
19+
20+// AcquireHostLock takes the lock at path (the default when empty) without
21+// waiting. The kernel releases it when the process dies, so a crash never leaves
22+// it stuck.
23+func AcquireHostLock(path string) (*HostLock, error) {
24+ if path == "" {
25+ path = DefaultLockFile
26+ }
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
29+ }
30+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
31+ if err != nil {
32+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
33+ }
34+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
35+ _ = f.Close()
36+ return nil, fmt.Errorf("another ermite-runner is running on this host (lock %s): only one runner per host is supported, because reaping removes everything the runner owns: %w", path, err)
37+ }
38+ return &HostLock{f: f}, nil
39+}
40+
41+// Release drops the lock; releasing twice is fine.
42+func (l *HostLock) Release() error {
43+ if l == nil || l.f == nil {
44+ return nil
45+ }
46+ err := l.f.Close()
47+ l.f = nil
48+ return err
49+}
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// HostLock is the host-wide single-instance lock. Reaping removes every process,
14+// device, firewall rule and Docker object labelled as the runner's own, so it is
15+// only safe when no other runner of the same host is alive: the lock lives at a
16+// fixed path of the host (DefaultLockFile), not in a state directory, which two
17+// runners could choose differently, and it is taken by both isolation modes.
18+type HostLock struct{ f *os.File }
19+
20+// AcquireHostLock takes the lock at path (the default when empty) without
21+// waiting. The kernel releases it when the process dies, so a crash never leaves
22+// it stuck.
23+func AcquireHostLock(path string) (*HostLock, error) {
24+ if path == "" {
25+ path = DefaultLockFile
26+ }
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
29+ }
30+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
31+ if err != nil {
32+ return nil, fmt.Errorf("host lock %s: %w (set %s for another path)", path, err, EnvLockFile)
33+ }
34+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
35+ _ = f.Close()
36+ return nil, fmt.Errorf("another ermite-runner is running on this host (lock %s): only one runner per host is supported, because reaping removes everything the runner owns: %w", path, err)
37+ }
38+ return &HostLock{f: f}, nil
39+}
40+
41+// Release drops the lock; releasing twice is fine.
42+func (l *HostLock) Release() error {
43+ if l == nil || l.f == nil {
44+ return nil
45+ }
46+ err := l.f.Close()
47+ l.f = nil
48+ return err
49+}
M internal/sandbox/sandbox.go +78 -12
@@ -24,9 +24,31 @@ const (
2424 Container Mode = "container"
2525 )
2626
27+// Isolation is the effective isolation of a driver, reported by the host at
28+// registration and shown on the job page.
29+type Isolation string
30+
31+// Effective isolations.
32+const (
33+ IsolationVM Isolation = "vm"
34+ IsolationContainerSysbox Isolation = "container_sysbox"
35+ IsolationContainerPrivileged Isolation = "container_privileged"
36+)
37+
2738 // ErrCapacity is returned by Driver.New when every slot is in use.
2839 var ErrCapacity = errors.New("sandbox: no free slot")
2940
41+// ErrLeaked is wrapped by the error of Start or Stop when the cleanup of a
42+// sandbox is incomplete: something it created (process, network device,
43+// firewall rules, files, containers) may still exist and its slot stays taken.
44+// The host should stop assigning jobs to this runner slot, let running jobs
45+// finish and call Driver.Reap, which retries the cleanup and frees the slot.
46+var ErrLeaked = errors.New("sandbox: cleanup incomplete, resources leaked")
47+
48+// ErrBusy is returned by Driver.Reap while sandboxes are running: reaping
49+// removes everything labelled as ours and would destroy them.
50+var ErrBusy = errors.New("sandbox: cannot reap while sandboxes are running")
51+
3052 // JobSpec describes the sandbox of one job. Zero resource fields take the
3153 // configured defaults.
3254 type JobSpec struct {
@@ -45,12 +67,20 @@ type Conn io.ReadWriteCloser
4567
4668 // Sandbox isolates one job. A Sandbox is single use: Start once, then Stop.
4769 type Sandbox interface {
70+ // ID is the sandbox's identifier ("er" + 8 hex digits), assigned by
71+ // Driver.New. It names the sandbox's files and Docker objects, so logs and
72+ // leftover reports can be matched with them.
73+ ID() string
4874 // Start creates the sandbox, boots it and waits for the executor inside to
49- // connect back. On error everything created so far has been removed.
75+ // connect back. It aborts when ctx ends or when Stop is called from another
76+ // goroutine. On error everything created so far has been removed, or the
77+ // error wraps ErrLeaked.
5078 Start(ctx context.Context, spec JobSpec) (Conn, error)
5179 // Stop destroys the sandbox and everything it created (processes, network
5280 // devices, firewall rules, files). It is idempotent, safe after a failed or
53- // never-made Start, and runs to completion even when ctx is already done.
81+ // never-made Start, runs to completion even when ctx is already done, and
82+ // aborts a Start in progress. When something could not be removed the error
83+ // wraps ErrLeaked.
5484 Stop(ctx context.Context) error
5585 }
5686
@@ -58,26 +88,34 @@ type Sandbox interface {
5888 type Driver interface {
5989 // Mode is VM or Container.
6090 Mode() Mode
91+ // Isolation is the effective isolation (vm, container_sysbox,
92+ // container_privileged), for registration with ermite.
93+ Isolation() Isolation
6194 // New returns a fresh Sandbox bound to a free slot (ErrCapacity if none).
62- // The slot is released by Stop.
95+ // The slot is released by Stop, unless the cleanup leaked (ErrLeaked).
6396 New() (Sandbox, error)
6497 // Reap removes everything a previous run of the runner left behind
65- // (leftover processes, devices, firewall rules, containers, files). It must
66- // be called once at startup, before any New.
98+ // (leftover processes, devices, firewall rules, containers, files) and
99+ // frees the slots of leaked sandboxes. Call it once at startup, before any
100+ // New, and after ErrLeaked when no sandbox is running; it returns ErrBusy
101+ // while one is.
67102 Reap(ctx context.Context) error
68- // Close releases the driver's own resources (DNS forwarder, API client).
69- // Sandboxes still running are not stopped.
103+ // Close releases the driver's own resources (DNS forwarder, API client,
104+ // host lock). Sandboxes still running are not stopped.
70105 Close() error
71106 }
72107
73108 // Slots hands out small integers, lowest first. Slots identify a sandbox's uid,
74-// network device and /30 subnet, so two live sandboxes never collide.
109+// network device and /30 subnet, so two live sandboxes never collide. A slot
110+// whose sandbox leaked stays taken (its device name and subnet may still be in
111+// use) until ReleaseLeaked.
75112 type Slots struct {
76- used []bool
113+ used []bool
114+ leaked []bool
77115 }
78116
79117 // NewSlots returns a pool of n slots.
80-func NewSlots(n int) *Slots { return &Slots{used: make([]bool, n)} }
118+func NewSlots(n int) *Slots { return &Slots{used: make([]bool, n), leaked: make([]bool, n)} }
81119
82120 // Acquire returns the lowest free slot, or ErrCapacity. Callers serialise
83121 // access (the drivers hold their mutex).
@@ -94,11 +132,27 @@ func (s *Slots) Acquire() (int, error) {
94132 // Release frees a slot; releasing a free or unknown slot is a no-op.
95133 func (s *Slots) Release(i int) {
96134 if i >= 0 && i < len(s.used) {
97- s.used[i] = false
135+ s.used[i], s.leaked[i] = false, false
136+ }
137+}
138+
139+// MarkLeaked keeps slot i taken after a failed cleanup.
140+func (s *Slots) MarkLeaked(i int) {
141+ if i >= 0 && i < len(s.used) {
142+ s.used[i], s.leaked[i] = true, true
143+ }
144+}
145+
146+// ReleaseLeaked frees every leaked slot: the caller has reaped what they held.
147+func (s *Slots) ReleaseLeaked() {
148+ for i := range s.used {
149+ if s.leaked[i] {
150+ s.used[i], s.leaked[i] = false, false
151+ }
98152 }
99153 }
100154
101-// InUse reports how many slots are taken.
155+// InUse reports how many slots are taken, leaked ones included.
102156 func (s *Slots) InUse() int {
103157 n := 0
104158 for _, u := range s.used {
@@ -108,3 +162,15 @@ func (s *Slots) InUse() int {
108162 }
109163 return n
110164 }
165+
166+// Busy reports how many slots belong to a sandbox that is not leaked: running,
167+// starting, or handed out by New and not yet stopped.
168+func (s *Slots) Busy() int {
169+ n := 0
170+ for i, u := range s.used {
171+ if u && !s.leaked[i] {
172+ n++
173+ }
174+ }
175+ return n
176+}
@@ -24,9 +24,31 @@ const (
24 Container Mode = "container"24 Container Mode = "container"
25 )25 )
26 26
27+// Isolation is the effective isolation of a driver, reported by the host at
28+// registration and shown on the job page.
29+type Isolation string
30+
31+// Effective isolations.
32+const (
33+ IsolationVM Isolation = "vm"
34+ IsolationContainerSysbox Isolation = "container_sysbox"
35+ IsolationContainerPrivileged Isolation = "container_privileged"
36+)
37+
27 // ErrCapacity is returned by Driver.New when every slot is in use.38 // ErrCapacity is returned by Driver.New when every slot is in use.
28 var ErrCapacity = errors.New("sandbox: no free slot")39 var ErrCapacity = errors.New("sandbox: no free slot")
29 40
41+// ErrLeaked is wrapped by the error of Start or Stop when the cleanup of a
42+// sandbox is incomplete: something it created (process, network device,
43+// firewall rules, files, containers) may still exist and its slot stays taken.
44+// The host should stop assigning jobs to this runner slot, let running jobs
45+// finish and call Driver.Reap, which retries the cleanup and frees the slot.
46+var ErrLeaked = errors.New("sandbox: cleanup incomplete, resources leaked")
47+
48+// ErrBusy is returned by Driver.Reap while sandboxes are running: reaping
49+// removes everything labelled as ours and would destroy them.
50+var ErrBusy = errors.New("sandbox: cannot reap while sandboxes are running")
51+
30 // JobSpec describes the sandbox of one job. Zero resource fields take the52 // JobSpec describes the sandbox of one job. Zero resource fields take the
31 // configured defaults.53 // configured defaults.
32 type JobSpec struct {54 type JobSpec struct {
@@ -45,12 +67,20 @@ type Conn io.ReadWriteCloser
45 67
46 // Sandbox isolates one job. A Sandbox is single use: Start once, then Stop.68 // Sandbox isolates one job. A Sandbox is single use: Start once, then Stop.
47 type Sandbox interface {69 type Sandbox interface {
70+ // ID is the sandbox's identifier ("er" + 8 hex digits), assigned by
71+ // Driver.New. It names the sandbox's files and Docker objects, so logs and
72+ // leftover reports can be matched with them.
73+ ID() string
48 // Start creates the sandbox, boots it and waits for the executor inside to74 // Start creates the sandbox, boots it and waits for the executor inside to
49- // connect back. On error everything created so far has been removed.75+ // connect back. It aborts when ctx ends or when Stop is called from another
76+ // goroutine. On error everything created so far has been removed, or the
77+ // error wraps ErrLeaked.
50 Start(ctx context.Context, spec JobSpec) (Conn, error)78 Start(ctx context.Context, spec JobSpec) (Conn, error)
51 // Stop destroys the sandbox and everything it created (processes, network79 // Stop destroys the sandbox and everything it created (processes, network
52 // devices, firewall rules, files). It is idempotent, safe after a failed or80 // devices, firewall rules, files). It is idempotent, safe after a failed or
53- // never-made Start, and runs to completion even when ctx is already done.81+ // never-made Start, runs to completion even when ctx is already done, and
82+ // aborts a Start in progress. When something could not be removed the error
83+ // wraps ErrLeaked.
54 Stop(ctx context.Context) error84 Stop(ctx context.Context) error
55 }85 }
56 86
@@ -58,26 +88,34 @@ type Sandbox interface {
58 type Driver interface {88 type Driver interface {
59 // Mode is VM or Container.89 // Mode is VM or Container.
60 Mode() Mode90 Mode() Mode
91+ // Isolation is the effective isolation (vm, container_sysbox,
92+ // container_privileged), for registration with ermite.
93+ Isolation() Isolation
61 // New returns a fresh Sandbox bound to a free slot (ErrCapacity if none).94 // New returns a fresh Sandbox bound to a free slot (ErrCapacity if none).
62- // The slot is released by Stop.95+ // The slot is released by Stop, unless the cleanup leaked (ErrLeaked).
63 New() (Sandbox, error)96 New() (Sandbox, error)
64 // Reap removes everything a previous run of the runner left behind97 // Reap removes everything a previous run of the runner left behind
65- // (leftover processes, devices, firewall rules, containers, files). It must98+ // (leftover processes, devices, firewall rules, containers, files) and
66- // be called once at startup, before any New.99+ // frees the slots of leaked sandboxes. Call it once at startup, before any
100+ // New, and after ErrLeaked when no sandbox is running; it returns ErrBusy
101+ // while one is.
67 Reap(ctx context.Context) error102 Reap(ctx context.Context) error
68- // Close releases the driver's own resources (DNS forwarder, API client).103+ // Close releases the driver's own resources (DNS forwarder, API client,
69- // Sandboxes still running are not stopped.104+ // host lock). Sandboxes still running are not stopped.
70 Close() error105 Close() error
71 }106 }
72 107
73 // Slots hands out small integers, lowest first. Slots identify a sandbox's uid,108 // Slots hands out small integers, lowest first. Slots identify a sandbox's uid,
74-// network device and /30 subnet, so two live sandboxes never collide.109+// network device and /30 subnet, so two live sandboxes never collide. A slot
110+// whose sandbox leaked stays taken (its device name and subnet may still be in
111+// use) until ReleaseLeaked.
75 type Slots struct {112 type Slots struct {
76- used []bool113+ used []bool
114+ leaked []bool
77 }115 }
78 116
79 // NewSlots returns a pool of n slots.117 // NewSlots returns a pool of n slots.
80-func NewSlots(n int) *Slots { return &Slots{used: make([]bool, n)} }118+func NewSlots(n int) *Slots { return &Slots{used: make([]bool, n), leaked: make([]bool, n)} }
81 119
82 // Acquire returns the lowest free slot, or ErrCapacity. Callers serialise120 // Acquire returns the lowest free slot, or ErrCapacity. Callers serialise
83 // access (the drivers hold their mutex).121 // access (the drivers hold their mutex).
@@ -94,11 +132,27 @@ func (s *Slots) Acquire() (int, error) {
94 // Release frees a slot; releasing a free or unknown slot is a no-op.132 // Release frees a slot; releasing a free or unknown slot is a no-op.
95 func (s *Slots) Release(i int) {133 func (s *Slots) Release(i int) {
96 if i >= 0 && i < len(s.used) {134 if i >= 0 && i < len(s.used) {
97- s.used[i] = false135+ s.used[i], s.leaked[i] = false, false
136+ }
137+}
138+
139+// MarkLeaked keeps slot i taken after a failed cleanup.
140+func (s *Slots) MarkLeaked(i int) {
141+ if i >= 0 && i < len(s.used) {
142+ s.used[i], s.leaked[i] = true, true
143+ }
144+}
145+
146+// ReleaseLeaked frees every leaked slot: the caller has reaped what they held.
147+func (s *Slots) ReleaseLeaked() {
148+ for i := range s.used {
149+ if s.leaked[i] {
150+ s.used[i], s.leaked[i] = false, false
151+ }
98 }152 }
99 }153 }
100 154
101-// InUse reports how many slots are taken.155+// InUse reports how many slots are taken, leaked ones included.
102 func (s *Slots) InUse() int {156 func (s *Slots) InUse() int {
103 n := 0157 n := 0
104 for _, u := range s.used {158 for _, u := range s.used {
@@ -108,3 +162,15 @@ func (s *Slots) InUse() int {
108 }162 }
109 return n163 return n
110 }164 }
165+
166+// Busy reports how many slots belong to a sandbox that is not leaked: running,
167+// starting, or handed out by New and not yet stopped.
168+func (s *Slots) Busy() int {
169+ n := 0
170+ for i, u := range s.used {
171+ if u && !s.leaked[i] {
172+ n++
173+ }
174+ }
175+ return n
176+}
M internal/sandbox/sandbox_test.go +1 -0
@@ -123,6 +123,7 @@ func TestIDs(t *testing.T) {
123123 type fakeDriver struct{ mode Mode }
124124
125125 func (f fakeDriver) Mode() Mode { return f.mode }
126+func (f fakeDriver) Isolation() Isolation { return Isolation(f.mode) }
126127 func (fakeDriver) New() (Sandbox, error) { return nil, nil }
127128 func (fakeDriver) Reap(context.Context) error { return nil }
128129 func (fakeDriver) Close() error { return nil }
@@ -123,6 +123,7 @@ func TestIDs(t *testing.T) {
123 type fakeDriver struct{ mode Mode }123 type fakeDriver struct{ mode Mode }
124 124
125 func (f fakeDriver) Mode() Mode { return f.mode }125 func (f fakeDriver) Mode() Mode { return f.mode }
126+func (f fakeDriver) Isolation() Isolation { return Isolation(f.mode) }
126 func (fakeDriver) New() (Sandbox, error) { return nil, nil }127 func (fakeDriver) New() (Sandbox, error) { return nil, nil }
127 func (fakeDriver) Reap(context.Context) error { return nil }128 func (fakeDriver) Reap(context.Context) error { return nil }
128 func (fakeDriver) Close() error { return nil }129 func (fakeDriver) Close() error { return nil }
A internal/sandbox/space.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/zerowrap"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// FreeMiB returns the space available to unprivileged users on the filesystem
13+// of path, in MiB.
14+func FreeMiB(path string) (int64, error) {
15+ var st unix.Statfs_t
16+ if err := unix.Statfs(path, &st); err != nil {
17+ return 0, err
18+ }
19+ return int64(st.Bavail) * int64(st.Bsize) >> 20, nil
20+}
21+
22+// WarnIfLowSpace logs a warning when the filesystem of dir has less free space
23+// than capacity sandboxes of diskMiB would need if they all filled their sparse
24+// scratch disk. It is only a warning: scratch disks are sparse and jobs rarely
25+// fill them, so a smaller filesystem works until it does not. free is FreeMiB in
26+// production.
27+func WarnIfLowSpace(ctx context.Context, dir string, capacity, diskMiB int, free func(string) (int64, error)) {
28+ if free == nil {
29+ free = FreeMiB
30+ }
31+ log := zerowrap.FromCtx(ctx)
32+ have, err := free(dir)
33+ if err != nil {
34+ log.Warn().Err(err).Str("dir", dir).Msg("cannot check the free space of the state directory")
35+ return
36+ }
37+ need := int64(capacity) * int64(diskMiB)
38+ if have < need {
39+ log.Warn().Str("dir", dir).Int64("free_mib", have).Int64("worst_case_mib", need).
40+ Int("capacity", capacity).Int("job_disk_mib", diskMiB).
41+ Msg("the state directory has less free space than capacity x job disk: jobs that fill their scratch disk will fail with 'no space left on device'")
42+ }
43+}
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: MIT
2+
3+package sandbox
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/zerowrap"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// FreeMiB returns the space available to unprivileged users on the filesystem
13+// of path, in MiB.
14+func FreeMiB(path string) (int64, error) {
15+ var st unix.Statfs_t
16+ if err := unix.Statfs(path, &st); err != nil {
17+ return 0, err
18+ }
19+ return int64(st.Bavail) * int64(st.Bsize) >> 20, nil
20+}
21+
22+// WarnIfLowSpace logs a warning when the filesystem of dir has less free space
23+// than capacity sandboxes of diskMiB would need if they all filled their sparse
24+// scratch disk. It is only a warning: scratch disks are sparse and jobs rarely
25+// fill them, so a smaller filesystem works until it does not. free is FreeMiB in
26+// production.
27+func WarnIfLowSpace(ctx context.Context, dir string, capacity, diskMiB int, free func(string) (int64, error)) {
28+ if free == nil {
29+ free = FreeMiB
30+ }
31+ log := zerowrap.FromCtx(ctx)
32+ have, err := free(dir)
33+ if err != nil {
34+ log.Warn().Err(err).Str("dir", dir).Msg("cannot check the free space of the state directory")
35+ return
36+ }
37+ need := int64(capacity) * int64(diskMiB)
38+ if have < need {
39+ log.Warn().Str("dir", dir).Int64("free_mib", have).Int64("worst_case_mib", need).
40+ Int("capacity", capacity).Int("job_disk_mib", diskMiB).
41+ Msg("the state directory has less free space than capacity x job disk: jobs that fill their scratch disk will fail with 'no space left on device'")
42+ }
43+}

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