feat(sandbox/container): add the Docker container driver with label-based reaping
- commit
- ecb03620040be1053832b1536a492f50eec3f2d5
- author
- brice · · 2026-10-12 15:35 UTC
- parent
- 82488a8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +1447 -0
A internal/sandbox/container/container_test.go +556 -0
| @@ -0,0 +1,556 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package container | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "net" | |
| 10 | + | "net/netip" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "slices" | |
| 14 | + | "strings" | |
| 15 | + | "sync" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 20 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 21 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 22 | + | "bnema.dev/ermite-runner/internal/sandbox/sandboxtest" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // fakeEngine plays the Docker daemon. When a container "starts", it plays the | |
| 26 | + | // executor inside it by connecting to the bind-mounted socket. | |
| 27 | + | type fakeEngine struct { | |
| 28 | + | mu sync.Mutex | |
| 29 | + | info daemonInfo | |
| 30 | + | infoErr error | |
| 31 | + | imageErr error | |
| 32 | + | networks map[string]networkSpec | |
| 33 | + | volumes map[string]map[string]string | |
| 34 | + | containers map[string]containerSpec | |
| 35 | + | failStart error | |
| 36 | + | noConnect bool | |
| 37 | + | exitOnRun bool | |
| 38 | + | removeErr error | |
| 39 | + | waiters map[string]chan exitResult | |
| 40 | + | seq int | |
| 41 | + | pulled []string | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func newFake() *fakeEngine { | |
| 45 | + | return &fakeEngine{ | |
| 46 | + | info: daemonInfo{OSType: "linux", Runtimes: []string{"runc"}}, | |
| 47 | + | networks: map[string]networkSpec{}, | |
| 48 | + | volumes: map[string]map[string]string{}, | |
| 49 | + | containers: map[string]containerSpec{}, | |
| 50 | + | waiters: map[string]chan exitResult{}, | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (f *fakeEngine) Info(context.Context) (daemonInfo, error) { return f.info, f.infoErr } | |
| 55 | + | func (f *fakeEngine) EnsureImage(_ context.Context, ref string) error { | |
| 56 | + | f.pulled = append(f.pulled, ref) | |
| 57 | + | return f.imageErr | |
| 58 | + | } | |
| 59 | + | func (f *fakeEngine) CreateNetwork(_ context.Context, s networkSpec) (string, error) { | |
| 60 | + | f.mu.Lock() | |
| 61 | + | defer f.mu.Unlock() | |
| 62 | + | f.seq++ | |
| 63 | + | id := fmt.Sprintf("net%d", f.seq) | |
| 64 | + | f.networks[id] = s | |
| 65 | + | return id, nil | |
| 66 | + | } | |
| 67 | + | func (f *fakeEngine) RemoveNetwork(_ context.Context, id string) error { | |
| 68 | + | f.mu.Lock() | |
| 69 | + | defer f.mu.Unlock() | |
| 70 | + | delete(f.networks, id) | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | func (f *fakeEngine) ListNetworks(context.Context, string) ([]string, error) { | |
| 74 | + | f.mu.Lock() | |
| 75 | + | defer f.mu.Unlock() | |
| 76 | + | var out []string | |
| 77 | + | for id, n := range f.networks { | |
| 78 | + | if _, ok := n.Labels[sandbox.LabelJob]; ok { | |
| 79 | + | out = append(out, id) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return out, nil | |
| 83 | + | } | |
| 84 | + | func (f *fakeEngine) CreateVolume(_ context.Context, name string, labels map[string]string) error { | |
| 85 | + | f.mu.Lock() | |
| 86 | + | defer f.mu.Unlock() | |
| 87 | + | f.volumes[name] = labels | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | func (f *fakeEngine) RemoveVolume(_ context.Context, name string) error { | |
| 91 | + | f.mu.Lock() | |
| 92 | + | defer f.mu.Unlock() | |
| 93 | + | delete(f.volumes, name) | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | func (f *fakeEngine) ListVolumes(context.Context, string) ([]string, error) { | |
| 97 | + | f.mu.Lock() | |
| 98 | + | defer f.mu.Unlock() | |
| 99 | + | var out []string | |
| 100 | + | for n := range f.volumes { | |
| 101 | + | out = append(out, n) | |
| 102 | + | } | |
| 103 | + | return out, nil | |
| 104 | + | } | |
| 105 | + | func (f *fakeEngine) CreateContainer(_ context.Context, s containerSpec) (string, error) { | |
| 106 | + | f.mu.Lock() | |
| 107 | + | defer f.mu.Unlock() | |
| 108 | + | f.seq++ | |
| 109 | + | id := fmt.Sprintf("ctr%d", f.seq) | |
| 110 | + | f.containers[id] = s | |
| 111 | + | f.waiters[id] = make(chan exitResult, 1) | |
| 112 | + | return id, nil | |
| 113 | + | } | |
| 114 | + | func (f *fakeEngine) StartContainer(_ context.Context, id string) error { | |
| 115 | + | f.mu.Lock() | |
| 116 | + | spec := f.containers[id] | |
| 117 | + | fail, noConnect, exitOnRun := f.failStart, f.noConnect, f.exitOnRun | |
| 118 | + | w := f.waiters[id] | |
| 119 | + | f.mu.Unlock() | |
| 120 | + | if fail != nil { | |
| 121 | + | return fail | |
| 122 | + | } | |
| 123 | + | if exitOnRun { | |
| 124 | + | w <- exitResult{Code: 125} | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | if noConnect { | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | // The host side of the bind mount is the source of the run-dir mount. | |
| 131 | + | var sock string | |
| 132 | + | for _, b := range spec.Binds { | |
| 133 | + | if b.Target == sandbox.ContainerRunDir { | |
| 134 | + | sock = filepath.Join(b.Source, sandbox.ContainerSocket) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | go func() { | |
| 138 | + | c, err := net.Dial("unix", sock) | |
| 139 | + | if err != nil { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | defer func() { _ = c.Close() }() | |
| 143 | + | g := execstream.NewConn(c) | |
| 144 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Sandbox: "container"}}}) | |
| 145 | + | if _, err := g.Recv(); err != nil { | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: []byte("hello from fake container\n")}}}) | |
| 149 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}}) | |
| 150 | + | _, _ = g.Recv() | |
| 151 | + | }() | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | func (f *fakeEngine) WaitExit(_ context.Context, id string) <-chan exitResult { | |
| 155 | + | f.mu.Lock() | |
| 156 | + | defer f.mu.Unlock() | |
| 157 | + | return f.waiters[id] | |
| 158 | + | } | |
| 159 | + | func (f *fakeEngine) RemoveContainer(_ context.Context, id string) error { | |
| 160 | + | f.mu.Lock() | |
| 161 | + | defer f.mu.Unlock() | |
| 162 | + | if f.removeErr != nil { | |
| 163 | + | return f.removeErr | |
| 164 | + | } | |
| 165 | + | delete(f.containers, id) | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | func (f *fakeEngine) ListContainers(context.Context, string) ([]string, error) { | |
| 169 | + | f.mu.Lock() | |
| 170 | + | defer f.mu.Unlock() | |
| 171 | + | var out []string | |
| 172 | + | for id := range f.containers { | |
| 173 | + | out = append(out, id) | |
| 174 | + | } | |
| 175 | + | return out, nil | |
| 176 | + | } | |
| 177 | + | func (f *fakeEngine) LogsTail(context.Context, string, int) (string, error) { | |
| 178 | + | return "fake container log line", nil | |
| 179 | + | } | |
| 180 | + | func (f *fakeEngine) Close() error { return nil } | |
| 181 | + | ||
| 182 | + | func (f *fakeEngine) objects() []string { | |
| 183 | + | f.mu.Lock() | |
| 184 | + | defer f.mu.Unlock() | |
| 185 | + | var out []string | |
| 186 | + | for id := range f.containers { | |
| 187 | + | out = append(out, "container "+id) | |
| 188 | + | } | |
| 189 | + | for id := range f.networks { | |
| 190 | + | out = append(out, "network "+id) | |
| 191 | + | } | |
| 192 | + | for n := range f.volumes { | |
| 193 | + | out = append(out, "volume "+n) | |
| 194 | + | } | |
| 195 | + | return out | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | type cenv struct { | |
| 199 | + | cfg sandbox.Config | |
| 200 | + | eng *fakeEngine | |
| 201 | + | plumb *sandboxtest.Plumbing | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func newCEnv(t *testing.T) *cenv { | |
| 205 | + | t.Helper() | |
| 206 | + | dir := t.TempDir() | |
| 207 | + | exe := filepath.Join(dir, "runner") | |
| 208 | + | sandboxtest.WriteStaticELF(t, exe) | |
| 209 | + | return &cenv{ | |
| 210 | + | eng: newFake(), | |
| 211 | + | plumb: sandboxtest.NewPlumbing(), | |
| 212 | + | cfg: sandbox.Config{ | |
| 213 | + | Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2, | |
| 214 | + | JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 1024, UIDBase: 900000, | |
| 215 | + | Network: netip.MustParsePrefix("10.200.0.0/16"), Executable: exe, DNSPort: 0, | |
| 216 | + | }, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (e *cenv) driver(t *testing.T) *Driver { | |
| 221 | + | t.Helper() | |
| 222 | + | d, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb, ConnectTimeout: 3 * time.Second}) | |
| 223 | + | if err != nil { | |
| 224 | + | t.Fatal(err) | |
| 225 | + | } | |
| 226 | + | t.Cleanup(func() { _ = d.Close() }) | |
| 227 | + | return d | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (e *cenv) leftovers() []string { | |
| 231 | + | out := e.eng.objects() | |
| 232 | + | if n := e.plumb.Live(); n != 0 { | |
| 233 | + | out = append(out, fmt.Sprintf("%d attached guests", n)) | |
| 234 | + | } | |
| 235 | + | ents, _ := os.ReadDir(filepath.Join(e.cfg.StateDir, "containers")) | |
| 236 | + | for _, d := range ents { | |
| 237 | + | out = append(out, "dir "+d.Name()) | |
| 238 | + | } | |
| 239 | + | return out | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func stub(t *testing.T, conn sandbox.Conn) (string, int) { | |
| 243 | + | t.Helper() | |
| 244 | + | var out strings.Builder | |
| 245 | + | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 246 | + | if err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | return out.String(), int(exit.Code) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestRootlessDockerIsRefused(t *testing.T) { | |
| 253 | + | e := newCEnv(t) | |
| 254 | + | e.eng.info.Rootless = true | |
| 255 | + | _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}) | |
| 256 | + | if err == nil || !strings.Contains(err.Error(), "rootless") || !strings.Contains(err.Error(), "rootful") { | |
| 257 | + | t.Fatalf("want a clear rootless error, got %v", err) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestRuntimeSelection(t *testing.T) { | |
| 262 | + | e := newCEnv(t) | |
| 263 | + | d := e.driver(t) | |
| 264 | + | if !d.Privileged() || d.runtime != "" { | |
| 265 | + | t.Fatal("without sysbox the driver must run privileged") | |
| 266 | + | } | |
| 267 | + | e2 := newCEnv(t) | |
| 268 | + | e2.eng.info.Runtimes = []string{"runc", "sysbox-runc"} | |
| 269 | + | d2 := e2.driver(t) | |
| 270 | + | if d2.Privileged() || d2.runtime != SysboxRuntime { | |
| 271 | + | t.Fatal("sysbox listed: must use it, unprivileged") | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestNewErrors(t *testing.T) { | |
| 276 | + | e := newCEnv(t) | |
| 277 | + | e.eng.infoErr = errors.New("connection refused") | |
| 278 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil || !strings.Contains(err.Error(), "DOCKER_HOST") { | |
| 279 | + | t.Fatalf("got %v", err) | |
| 280 | + | } | |
| 281 | + | e = newCEnv(t) | |
| 282 | + | e.eng.imageErr = errors.New("no such image") | |
| 283 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |
| 284 | + | t.Fatal("missing image accepted") | |
| 285 | + | } | |
| 286 | + | e = newCEnv(t) | |
| 287 | + | e.eng.info.OSType = "windows" | |
| 288 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |
| 289 | + | t.Fatal("windows daemon accepted") | |
| 290 | + | } | |
| 291 | + | e = newCEnv(t) | |
| 292 | + | e.cfg.Executable = "/bin/sh" | |
| 293 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |
| 294 | + | t.Skip("/bin/sh is static") | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestStartRunStop(t *testing.T) { | |
| 299 | + | e := newCEnv(t) | |
| 300 | + | d := e.driver(t) | |
| 301 | + | sb, err := d.New() | |
| 302 | + | if err != nil { | |
| 303 | + | t.Fatal(err) | |
| 304 | + | } | |
| 305 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "job-7", CPUs: 3, MemoryMiB: 2048}) | |
| 306 | + | if err != nil { | |
| 307 | + | t.Fatal(err) | |
| 308 | + | } | |
| 309 | + | out, code := stub(t, conn) | |
| 310 | + | if out != "hello from fake container\n" || code != 7 { | |
| 311 | + | t.Fatalf("out=%q code=%d", out, code) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // What was asked of Docker. | |
| 315 | + | e.eng.mu.Lock() | |
| 316 | + | var spec containerSpec | |
| 317 | + | for _, s := range e.eng.containers { | |
| 318 | + | spec = s | |
| 319 | + | } | |
| 320 | + | var netSpec networkSpec | |
| 321 | + | for _, n := range e.eng.networks { | |
| 322 | + | netSpec = n | |
| 323 | + | } | |
| 324 | + | e.eng.mu.Unlock() | |
| 325 | + | if !spec.Privileged || spec.Runtime != "" { | |
| 326 | + | t.Errorf("privileged fallback expected: %+v", spec) | |
| 327 | + | } | |
| 328 | + | if spec.Labels[sandbox.LabelJob] != "job-7" || netSpec.Labels[sandbox.LabelJob] != "job-7" { | |
| 329 | + | t.Errorf("every object must carry the job label: %v / %v", spec.Labels, netSpec.Labels) | |
| 330 | + | } | |
| 331 | + | if spec.MemoryMiB != 2048 || spec.CPUs != 3 { | |
| 332 | + | t.Errorf("resources: %+v", spec) | |
| 333 | + | } | |
| 334 | + | if !slices.Equal(spec.Entrypoint, []string{"/opt/ermite/ermite-runner", "init"}) { | |
| 335 | + | t.Errorf("entrypoint: %v", spec.Entrypoint) | |
| 336 | + | } | |
| 337 | + | var roExe, runDir bool | |
| 338 | + | for _, b := range spec.Binds { | |
| 339 | + | if b.Target == "/opt/ermite/ermite-runner" && b.ReadOnly && b.Source == e.cfg.Executable { | |
| 340 | + | roExe = true | |
| 341 | + | } | |
| 342 | + | if b.Target == sandbox.ContainerRunDir && !b.ReadOnly { | |
| 343 | + | runDir = true | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | if !roExe || !runDir { | |
| 347 | + | t.Errorf("binds: %+v", spec.Binds) | |
| 348 | + | } | |
| 349 | + | if len(spec.Volumes) != 1 || spec.Volumes[0].Target != sandbox.GuestScratch { | |
| 350 | + | t.Errorf("scratch volume: %+v", spec.Volumes) | |
| 351 | + | } | |
| 352 | + | if spec.DNS.String() != "10.200.0.1" || spec.IPv4.String() != "10.200.0.2" || netSpec.Bridge != "erbr0" || netSpec.Subnet.String() != "10.200.0.0/30" { | |
| 353 | + | t.Errorf("network: %+v %+v", spec, netSpec) | |
| 354 | + | } | |
| 355 | + | if !slices.Contains(spec.Env, "ERMITE_CONNECT=unix:/run/ermite/exec.sock") { | |
| 356 | + | t.Errorf("env: %v", spec.Env) | |
| 357 | + | } | |
| 358 | + | if e.plumb.Live() != 1 { | |
| 359 | + | t.Fatal("firewall policy not attached") | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | if err := sb.Stop(context.Background()); err != nil { | |
| 363 | + | t.Fatal(err) | |
| 364 | + | } | |
| 365 | + | if left := e.leftovers(); len(left) != 0 { | |
| 366 | + | t.Fatalf("leftovers: %v", left) | |
| 367 | + | } | |
| 368 | + | if err := sb.Stop(context.Background()); err != nil { | |
| 369 | + | t.Fatalf("Stop must be idempotent: %v", err) | |
| 370 | + | } | |
| 371 | + | if d.slots.InUse() != 0 { | |
| 372 | + | t.Fatal("slot not released") | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestSysboxJobIsNotPrivileged(t *testing.T) { | |
| 377 | + | e := newCEnv(t) | |
| 378 | + | e.eng.info.Runtimes = []string{"sysbox-runc", "runc"} | |
| 379 | + | d := e.driver(t) | |
| 380 | + | sb, _ := d.New() | |
| 381 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | stub(t, conn) | |
| 386 | + | e.eng.mu.Lock() | |
| 387 | + | for _, s := range e.eng.containers { | |
| 388 | + | if s.Privileged || s.Runtime != "sysbox-runc" { | |
| 389 | + | t.Errorf("sysbox job: %+v", s) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | e.eng.mu.Unlock() | |
| 393 | + | _ = sb.Stop(context.Background()) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestStartFailuresCleanEverything(t *testing.T) { | |
| 397 | + | cases := map[string]func(*cenv){ | |
| 398 | + | "container cannot start": func(e *cenv) { e.eng.failStart = errors.New("oci runtime error") }, | |
| 399 | + | "container exits early": func(e *cenv) { e.eng.exitOnRun = true }, | |
| 400 | + | "executor never connects": func(e *cenv) { | |
| 401 | + | e.eng.noConnect = true | |
| 402 | + | }, | |
| 403 | + | "firewall attach fails": func(e *cenv) { e.plumb.FailNext = errors.New("nft broke") }, | |
| 404 | + | } | |
| 405 | + | for name, setup := range cases { | |
| 406 | + | t.Run(name, func(t *testing.T) { | |
| 407 | + | e := newCEnv(t) | |
| 408 | + | setup(e) | |
| 409 | + | d, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb, ConnectTimeout: 300 * time.Millisecond}) | |
| 410 | + | if err != nil { | |
| 411 | + | t.Fatal(err) | |
| 412 | + | } | |
| 413 | + | defer func() { _ = d.Close() }() | |
| 414 | + | sb, _ := d.New() | |
| 415 | + | _, err = sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 416 | + | if err == nil { | |
| 417 | + | t.Fatal("Start succeeded") | |
| 418 | + | } | |
| 419 | + | if left := e.leftovers(); len(left) != 0 { | |
| 420 | + | t.Fatalf("leftovers: %v (err %v)", left, err) | |
| 421 | + | } | |
| 422 | + | if d.slots.InUse() != 0 { | |
| 423 | + | t.Fatal("slot leaked") | |
| 424 | + | } | |
| 425 | + | }) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func TestFailedStartIncludesContainerLogs(t *testing.T) { | |
| 430 | + | e := newCEnv(t) | |
| 431 | + | e.eng.exitOnRun = true | |
| 432 | + | d := e.driver(t) | |
| 433 | + | sb, _ := d.New() | |
| 434 | + | _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 435 | + | if err == nil || !strings.Contains(err.Error(), "fake container log line") { | |
| 436 | + | t.Fatalf("got %v", err) | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func TestCleanupFailureKeepsSlotAndIsRetriedByReap(t *testing.T) { | |
| 441 | + | e := newCEnv(t) | |
| 442 | + | d := e.driver(t) | |
| 443 | + | sb, _ := d.New() | |
| 444 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 445 | + | if err != nil { | |
| 446 | + | t.Fatal(err) | |
| 447 | + | } | |
| 448 | + | stub(t, conn) | |
| 449 | + | e.eng.removeErr = errors.New("daemon busy") | |
| 450 | + | if err := sb.Stop(context.Background()); err == nil { | |
| 451 | + | t.Fatal("Stop must report the failed removal") | |
| 452 | + | } | |
| 453 | + | if d.slots.InUse() != 1 { | |
| 454 | + | t.Fatal("the slot must stay taken while its bridge and subnet still exist") | |
| 455 | + | } | |
| 456 | + | e.eng.removeErr = nil | |
| 457 | + | if err := d.Reap(context.Background()); err != nil { | |
| 458 | + | t.Fatal(err) | |
| 459 | + | } | |
| 460 | + | if left := e.leftovers(); len(left) != 0 { | |
| 461 | + | t.Fatalf("Reap left %v", left) | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func TestReapRemovesLabelledObjectsOnly(t *testing.T) { | |
| 466 | + | e := newCEnv(t) | |
| 467 | + | d := e.driver(t) | |
| 468 | + | ctx := context.Background() | |
| 469 | + | // Leftovers of a crashed run, and an unrelated user's network. | |
| 470 | + | _, _ = e.eng.CreateContainer(ctx, containerSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}}) | |
| 471 | + | _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}}) | |
| 472 | + | _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "mine", Labels: map[string]string{"app": "other"}}) | |
| 473 | + | _ = e.eng.CreateVolume(ctx, "old", map[string]string{sandbox.LabelJob: "x"}) | |
| 474 | + | _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "er00112233"), 0o700) | |
| 475 | + | _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "keep-me"), 0o700) | |
| 476 | + | ||
| 477 | + | if err := d.Reap(ctx); err != nil { | |
| 478 | + | t.Fatal(err) | |
| 479 | + | } | |
| 480 | + | left := e.leftovers() | |
| 481 | + | slices.Sort(left) | |
| 482 | + | if len(left) != 2 || !strings.HasPrefix(left[0], "dir keep-me") || !strings.HasPrefix(left[1], "network") { | |
| 483 | + | t.Fatalf("only the unrelated network and directory may remain: %v", left) | |
| 484 | + | } | |
| 485 | + | if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "containers", "keep-me")); err != nil { | |
| 486 | + | t.Fatal("Reap removed a directory that is not ours") | |
| 487 | + | } | |
| 488 | + | if e.plumb.Resets != 1 { | |
| 489 | + | t.Fatal("nftables table not reset") | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | func TestCapacityAndSlotIsolation(t *testing.T) { | |
| 494 | + | e := newCEnv(t) | |
| 495 | + | d := e.driver(t) | |
| 496 | + | a, _ := d.New() | |
| 497 | + | b, _ := d.New() | |
| 498 | + | if _, err := d.New(); !errors.Is(err, sandbox.ErrCapacity) { | |
| 499 | + | t.Fatalf("got %v", err) | |
| 500 | + | } | |
| 501 | + | for _, sb := range []sandbox.Sandbox{a, b} { | |
| 502 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 503 | + | if err != nil { | |
| 504 | + | t.Fatal(err) | |
| 505 | + | } | |
| 506 | + | stub(t, conn) | |
| 507 | + | } | |
| 508 | + | ifaces := map[string]bool{} | |
| 509 | + | for iface := range e.plumb.Attached { | |
| 510 | + | ifaces[iface] = true | |
| 511 | + | } | |
| 512 | + | if !ifaces["erbr0"] || !ifaces["erbr1"] { | |
| 513 | + | t.Fatalf("each job needs its own bridge: %v", ifaces) | |
| 514 | + | } | |
| 515 | + | _ = a.Stop(context.Background()) | |
| 516 | + | _ = b.Stop(context.Background()) | |
| 517 | + | if left := e.leftovers(); len(left) != 0 { | |
| 518 | + | t.Fatalf("leftovers: %v", left) | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func TestStopWithCancelledContext(t *testing.T) { | |
| 523 | + | e := newCEnv(t) | |
| 524 | + | d := e.driver(t) | |
| 525 | + | sb, _ := d.New() | |
| 526 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 527 | + | if err != nil { | |
| 528 | + | t.Fatal(err) | |
| 529 | + | } | |
| 530 | + | stub(t, conn) | |
| 531 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 532 | + | cancel() | |
| 533 | + | if err := sb.Stop(ctx); err != nil { | |
| 534 | + | t.Fatal(err) | |
| 535 | + | } | |
| 536 | + | if left := e.leftovers(); len(left) != 0 { | |
| 537 | + | t.Fatalf("leftovers: %v", left) | |
| 538 | + | } | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | func TestDoubleStartRefused(t *testing.T) { | |
| 542 | + | e := newCEnv(t) | |
| 543 | + | d := e.driver(t) | |
| 544 | + | sb, _ := d.New() | |
| 545 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |
| 546 | + | if err != nil { | |
| 547 | + | t.Fatal(err) | |
| 548 | + | } | |
| 549 | + | stub(t, conn) | |
| 550 | + | if _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}); err == nil { | |
| 551 | + | t.Fatal("second Start accepted") | |
| 552 | + | } | |
| 553 | + | _ = sb.Stop(context.Background()) | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) } | |
| @@ -0,0 +1,556 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package container | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "net" | |||
| 10 | + | "net/netip" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "slices" | |||
| 14 | + | "strings" | |||
| 15 | + | "sync" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 20 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 21 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 22 | + | "bnema.dev/ermite-runner/internal/sandbox/sandboxtest" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // fakeEngine plays the Docker daemon. When a container "starts", it plays the | |||
| 26 | + | // executor inside it by connecting to the bind-mounted socket. | |||
| 27 | + | type fakeEngine struct { | |||
| 28 | + | mu sync.Mutex | |||
| 29 | + | info daemonInfo | |||
| 30 | + | infoErr error | |||
| 31 | + | imageErr error | |||
| 32 | + | networks map[string]networkSpec | |||
| 33 | + | volumes map[string]map[string]string | |||
| 34 | + | containers map[string]containerSpec | |||
| 35 | + | failStart error | |||
| 36 | + | noConnect bool | |||
| 37 | + | exitOnRun bool | |||
| 38 | + | removeErr error | |||
| 39 | + | waiters map[string]chan exitResult | |||
| 40 | + | seq int | |||
| 41 | + | pulled []string | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func newFake() *fakeEngine { | |||
| 45 | + | return &fakeEngine{ | |||
| 46 | + | info: daemonInfo{OSType: "linux", Runtimes: []string{"runc"}}, | |||
| 47 | + | networks: map[string]networkSpec{}, | |||
| 48 | + | volumes: map[string]map[string]string{}, | |||
| 49 | + | containers: map[string]containerSpec{}, | |||
| 50 | + | waiters: map[string]chan exitResult{}, | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (f *fakeEngine) Info(context.Context) (daemonInfo, error) { return f.info, f.infoErr } | |||
| 55 | + | func (f *fakeEngine) EnsureImage(_ context.Context, ref string) error { | |||
| 56 | + | f.pulled = append(f.pulled, ref) | |||
| 57 | + | return f.imageErr | |||
| 58 | + | } | |||
| 59 | + | func (f *fakeEngine) CreateNetwork(_ context.Context, s networkSpec) (string, error) { | |||
| 60 | + | f.mu.Lock() | |||
| 61 | + | defer f.mu.Unlock() | |||
| 62 | + | f.seq++ | |||
| 63 | + | id := fmt.Sprintf("net%d", f.seq) | |||
| 64 | + | f.networks[id] = s | |||
| 65 | + | return id, nil | |||
| 66 | + | } | |||
| 67 | + | func (f *fakeEngine) RemoveNetwork(_ context.Context, id string) error { | |||
| 68 | + | f.mu.Lock() | |||
| 69 | + | defer f.mu.Unlock() | |||
| 70 | + | delete(f.networks, id) | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | func (f *fakeEngine) ListNetworks(context.Context, string) ([]string, error) { | |||
| 74 | + | f.mu.Lock() | |||
| 75 | + | defer f.mu.Unlock() | |||
| 76 | + | var out []string | |||
| 77 | + | for id, n := range f.networks { | |||
| 78 | + | if _, ok := n.Labels[sandbox.LabelJob]; ok { | |||
| 79 | + | out = append(out, id) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return out, nil | |||
| 83 | + | } | |||
| 84 | + | func (f *fakeEngine) CreateVolume(_ context.Context, name string, labels map[string]string) error { | |||
| 85 | + | f.mu.Lock() | |||
| 86 | + | defer f.mu.Unlock() | |||
| 87 | + | f.volumes[name] = labels | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | func (f *fakeEngine) RemoveVolume(_ context.Context, name string) error { | |||
| 91 | + | f.mu.Lock() | |||
| 92 | + | defer f.mu.Unlock() | |||
| 93 | + | delete(f.volumes, name) | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | func (f *fakeEngine) ListVolumes(context.Context, string) ([]string, error) { | |||
| 97 | + | f.mu.Lock() | |||
| 98 | + | defer f.mu.Unlock() | |||
| 99 | + | var out []string | |||
| 100 | + | for n := range f.volumes { | |||
| 101 | + | out = append(out, n) | |||
| 102 | + | } | |||
| 103 | + | return out, nil | |||
| 104 | + | } | |||
| 105 | + | func (f *fakeEngine) CreateContainer(_ context.Context, s containerSpec) (string, error) { | |||
| 106 | + | f.mu.Lock() | |||
| 107 | + | defer f.mu.Unlock() | |||
| 108 | + | f.seq++ | |||
| 109 | + | id := fmt.Sprintf("ctr%d", f.seq) | |||
| 110 | + | f.containers[id] = s | |||
| 111 | + | f.waiters[id] = make(chan exitResult, 1) | |||
| 112 | + | return id, nil | |||
| 113 | + | } | |||
| 114 | + | func (f *fakeEngine) StartContainer(_ context.Context, id string) error { | |||
| 115 | + | f.mu.Lock() | |||
| 116 | + | spec := f.containers[id] | |||
| 117 | + | fail, noConnect, exitOnRun := f.failStart, f.noConnect, f.exitOnRun | |||
| 118 | + | w := f.waiters[id] | |||
| 119 | + | f.mu.Unlock() | |||
| 120 | + | if fail != nil { | |||
| 121 | + | return fail | |||
| 122 | + | } | |||
| 123 | + | if exitOnRun { | |||
| 124 | + | w <- exitResult{Code: 125} | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | if noConnect { | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | // The host side of the bind mount is the source of the run-dir mount. | |||
| 131 | + | var sock string | |||
| 132 | + | for _, b := range spec.Binds { | |||
| 133 | + | if b.Target == sandbox.ContainerRunDir { | |||
| 134 | + | sock = filepath.Join(b.Source, sandbox.ContainerSocket) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | go func() { | |||
| 138 | + | c, err := net.Dial("unix", sock) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return | |||
| 141 | + | } | |||
| 142 | + | defer func() { _ = c.Close() }() | |||
| 143 | + | g := execstream.NewConn(c) | |||
| 144 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Sandbox: "container"}}}) | |||
| 145 | + | if _, err := g.Recv(); err != nil { | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{Data: []byte("hello from fake container\n")}}}) | |||
| 149 | + | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}}) | |||
| 150 | + | _, _ = g.Recv() | |||
| 151 | + | }() | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | func (f *fakeEngine) WaitExit(_ context.Context, id string) <-chan exitResult { | |||
| 155 | + | f.mu.Lock() | |||
| 156 | + | defer f.mu.Unlock() | |||
| 157 | + | return f.waiters[id] | |||
| 158 | + | } | |||
| 159 | + | func (f *fakeEngine) RemoveContainer(_ context.Context, id string) error { | |||
| 160 | + | f.mu.Lock() | |||
| 161 | + | defer f.mu.Unlock() | |||
| 162 | + | if f.removeErr != nil { | |||
| 163 | + | return f.removeErr | |||
| 164 | + | } | |||
| 165 | + | delete(f.containers, id) | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | func (f *fakeEngine) ListContainers(context.Context, string) ([]string, error) { | |||
| 169 | + | f.mu.Lock() | |||
| 170 | + | defer f.mu.Unlock() | |||
| 171 | + | var out []string | |||
| 172 | + | for id := range f.containers { | |||
| 173 | + | out = append(out, id) | |||
| 174 | + | } | |||
| 175 | + | return out, nil | |||
| 176 | + | } | |||
| 177 | + | func (f *fakeEngine) LogsTail(context.Context, string, int) (string, error) { | |||
| 178 | + | return "fake container log line", nil | |||
| 179 | + | } | |||
| 180 | + | func (f *fakeEngine) Close() error { return nil } | |||
| 181 | + | ||||
| 182 | + | func (f *fakeEngine) objects() []string { | |||
| 183 | + | f.mu.Lock() | |||
| 184 | + | defer f.mu.Unlock() | |||
| 185 | + | var out []string | |||
| 186 | + | for id := range f.containers { | |||
| 187 | + | out = append(out, "container "+id) | |||
| 188 | + | } | |||
| 189 | + | for id := range f.networks { | |||
| 190 | + | out = append(out, "network "+id) | |||
| 191 | + | } | |||
| 192 | + | for n := range f.volumes { | |||
| 193 | + | out = append(out, "volume "+n) | |||
| 194 | + | } | |||
| 195 | + | return out | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | type cenv struct { | |||
| 199 | + | cfg sandbox.Config | |||
| 200 | + | eng *fakeEngine | |||
| 201 | + | plumb *sandboxtest.Plumbing | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func newCEnv(t *testing.T) *cenv { | |||
| 205 | + | t.Helper() | |||
| 206 | + | dir := t.TempDir() | |||
| 207 | + | exe := filepath.Join(dir, "runner") | |||
| 208 | + | sandboxtest.WriteStaticELF(t, exe) | |||
| 209 | + | return &cenv{ | |||
| 210 | + | eng: newFake(), | |||
| 211 | + | plumb: sandboxtest.NewPlumbing(), | |||
| 212 | + | cfg: sandbox.Config{ | |||
| 213 | + | Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2, | |||
| 214 | + | JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 1024, UIDBase: 900000, | |||
| 215 | + | Network: netip.MustParsePrefix("10.200.0.0/16"), Executable: exe, DNSPort: 0, | |||
| 216 | + | }, | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (e *cenv) driver(t *testing.T) *Driver { | |||
| 221 | + | t.Helper() | |||
| 222 | + | d, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb, ConnectTimeout: 3 * time.Second}) | |||
| 223 | + | if err != nil { | |||
| 224 | + | t.Fatal(err) | |||
| 225 | + | } | |||
| 226 | + | t.Cleanup(func() { _ = d.Close() }) | |||
| 227 | + | return d | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (e *cenv) leftovers() []string { | |||
| 231 | + | out := e.eng.objects() | |||
| 232 | + | if n := e.plumb.Live(); n != 0 { | |||
| 233 | + | out = append(out, fmt.Sprintf("%d attached guests", n)) | |||
| 234 | + | } | |||
| 235 | + | ents, _ := os.ReadDir(filepath.Join(e.cfg.StateDir, "containers")) | |||
| 236 | + | for _, d := range ents { | |||
| 237 | + | out = append(out, "dir "+d.Name()) | |||
| 238 | + | } | |||
| 239 | + | return out | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func stub(t *testing.T, conn sandbox.Conn) (string, int) { | |||
| 243 | + | t.Helper() | |||
| 244 | + | var out strings.Builder | |||
| 245 | + | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | |||
| 246 | + | if err != nil { | |||
| 247 | + | t.Fatal(err) | |||
| 248 | + | } | |||
| 249 | + | return out.String(), int(exit.Code) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestRootlessDockerIsRefused(t *testing.T) { | |||
| 253 | + | e := newCEnv(t) | |||
| 254 | + | e.eng.info.Rootless = true | |||
| 255 | + | _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}) | |||
| 256 | + | if err == nil || !strings.Contains(err.Error(), "rootless") || !strings.Contains(err.Error(), "rootful") { | |||
| 257 | + | t.Fatalf("want a clear rootless error, got %v", err) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestRuntimeSelection(t *testing.T) { | |||
| 262 | + | e := newCEnv(t) | |||
| 263 | + | d := e.driver(t) | |||
| 264 | + | if !d.Privileged() || d.runtime != "" { | |||
| 265 | + | t.Fatal("without sysbox the driver must run privileged") | |||
| 266 | + | } | |||
| 267 | + | e2 := newCEnv(t) | |||
| 268 | + | e2.eng.info.Runtimes = []string{"runc", "sysbox-runc"} | |||
| 269 | + | d2 := e2.driver(t) | |||
| 270 | + | if d2.Privileged() || d2.runtime != SysboxRuntime { | |||
| 271 | + | t.Fatal("sysbox listed: must use it, unprivileged") | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestNewErrors(t *testing.T) { | |||
| 276 | + | e := newCEnv(t) | |||
| 277 | + | e.eng.infoErr = errors.New("connection refused") | |||
| 278 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil || !strings.Contains(err.Error(), "DOCKER_HOST") { | |||
| 279 | + | t.Fatalf("got %v", err) | |||
| 280 | + | } | |||
| 281 | + | e = newCEnv(t) | |||
| 282 | + | e.eng.imageErr = errors.New("no such image") | |||
| 283 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |||
| 284 | + | t.Fatal("missing image accepted") | |||
| 285 | + | } | |||
| 286 | + | e = newCEnv(t) | |||
| 287 | + | e.eng.info.OSType = "windows" | |||
| 288 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |||
| 289 | + | t.Fatal("windows daemon accepted") | |||
| 290 | + | } | |||
| 291 | + | e = newCEnv(t) | |||
| 292 | + | e.cfg.Executable = "/bin/sh" | |||
| 293 | + | if _, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb}); err == nil { | |||
| 294 | + | t.Skip("/bin/sh is static") | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestStartRunStop(t *testing.T) { | |||
| 299 | + | e := newCEnv(t) | |||
| 300 | + | d := e.driver(t) | |||
| 301 | + | sb, err := d.New() | |||
| 302 | + | if err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "job-7", CPUs: 3, MemoryMiB: 2048}) | |||
| 306 | + | if err != nil { | |||
| 307 | + | t.Fatal(err) | |||
| 308 | + | } | |||
| 309 | + | out, code := stub(t, conn) | |||
| 310 | + | if out != "hello from fake container\n" || code != 7 { | |||
| 311 | + | t.Fatalf("out=%q code=%d", out, code) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // What was asked of Docker. | |||
| 315 | + | e.eng.mu.Lock() | |||
| 316 | + | var spec containerSpec | |||
| 317 | + | for _, s := range e.eng.containers { | |||
| 318 | + | spec = s | |||
| 319 | + | } | |||
| 320 | + | var netSpec networkSpec | |||
| 321 | + | for _, n := range e.eng.networks { | |||
| 322 | + | netSpec = n | |||
| 323 | + | } | |||
| 324 | + | e.eng.mu.Unlock() | |||
| 325 | + | if !spec.Privileged || spec.Runtime != "" { | |||
| 326 | + | t.Errorf("privileged fallback expected: %+v", spec) | |||
| 327 | + | } | |||
| 328 | + | if spec.Labels[sandbox.LabelJob] != "job-7" || netSpec.Labels[sandbox.LabelJob] != "job-7" { | |||
| 329 | + | t.Errorf("every object must carry the job label: %v / %v", spec.Labels, netSpec.Labels) | |||
| 330 | + | } | |||
| 331 | + | if spec.MemoryMiB != 2048 || spec.CPUs != 3 { | |||
| 332 | + | t.Errorf("resources: %+v", spec) | |||
| 333 | + | } | |||
| 334 | + | if !slices.Equal(spec.Entrypoint, []string{"/opt/ermite/ermite-runner", "init"}) { | |||
| 335 | + | t.Errorf("entrypoint: %v", spec.Entrypoint) | |||
| 336 | + | } | |||
| 337 | + | var roExe, runDir bool | |||
| 338 | + | for _, b := range spec.Binds { | |||
| 339 | + | if b.Target == "/opt/ermite/ermite-runner" && b.ReadOnly && b.Source == e.cfg.Executable { | |||
| 340 | + | roExe = true | |||
| 341 | + | } | |||
| 342 | + | if b.Target == sandbox.ContainerRunDir && !b.ReadOnly { | |||
| 343 | + | runDir = true | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | if !roExe || !runDir { | |||
| 347 | + | t.Errorf("binds: %+v", spec.Binds) | |||
| 348 | + | } | |||
| 349 | + | if len(spec.Volumes) != 1 || spec.Volumes[0].Target != sandbox.GuestScratch { | |||
| 350 | + | t.Errorf("scratch volume: %+v", spec.Volumes) | |||
| 351 | + | } | |||
| 352 | + | if spec.DNS.String() != "10.200.0.1" || spec.IPv4.String() != "10.200.0.2" || netSpec.Bridge != "erbr0" || netSpec.Subnet.String() != "10.200.0.0/30" { | |||
| 353 | + | t.Errorf("network: %+v %+v", spec, netSpec) | |||
| 354 | + | } | |||
| 355 | + | if !slices.Contains(spec.Env, "ERMITE_CONNECT=unix:/run/ermite/exec.sock") { | |||
| 356 | + | t.Errorf("env: %v", spec.Env) | |||
| 357 | + | } | |||
| 358 | + | if e.plumb.Live() != 1 { | |||
| 359 | + | t.Fatal("firewall policy not attached") | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | if err := sb.Stop(context.Background()); err != nil { | |||
| 363 | + | t.Fatal(err) | |||
| 364 | + | } | |||
| 365 | + | if left := e.leftovers(); len(left) != 0 { | |||
| 366 | + | t.Fatalf("leftovers: %v", left) | |||
| 367 | + | } | |||
| 368 | + | if err := sb.Stop(context.Background()); err != nil { | |||
| 369 | + | t.Fatalf("Stop must be idempotent: %v", err) | |||
| 370 | + | } | |||
| 371 | + | if d.slots.InUse() != 0 { | |||
| 372 | + | t.Fatal("slot not released") | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestSysboxJobIsNotPrivileged(t *testing.T) { | |||
| 377 | + | e := newCEnv(t) | |||
| 378 | + | e.eng.info.Runtimes = []string{"sysbox-runc", "runc"} | |||
| 379 | + | d := e.driver(t) | |||
| 380 | + | sb, _ := d.New() | |||
| 381 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 382 | + | if err != nil { | |||
| 383 | + | t.Fatal(err) | |||
| 384 | + | } | |||
| 385 | + | stub(t, conn) | |||
| 386 | + | e.eng.mu.Lock() | |||
| 387 | + | for _, s := range e.eng.containers { | |||
| 388 | + | if s.Privileged || s.Runtime != "sysbox-runc" { | |||
| 389 | + | t.Errorf("sysbox job: %+v", s) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | e.eng.mu.Unlock() | |||
| 393 | + | _ = sb.Stop(context.Background()) | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestStartFailuresCleanEverything(t *testing.T) { | |||
| 397 | + | cases := map[string]func(*cenv){ | |||
| 398 | + | "container cannot start": func(e *cenv) { e.eng.failStart = errors.New("oci runtime error") }, | |||
| 399 | + | "container exits early": func(e *cenv) { e.eng.exitOnRun = true }, | |||
| 400 | + | "executor never connects": func(e *cenv) { | |||
| 401 | + | e.eng.noConnect = true | |||
| 402 | + | }, | |||
| 403 | + | "firewall attach fails": func(e *cenv) { e.plumb.FailNext = errors.New("nft broke") }, | |||
| 404 | + | } | |||
| 405 | + | for name, setup := range cases { | |||
| 406 | + | t.Run(name, func(t *testing.T) { | |||
| 407 | + | e := newCEnv(t) | |||
| 408 | + | setup(e) | |||
| 409 | + | d, err := New(context.Background(), e.cfg, Options{engine: e.eng, Net: e.plumb, ConnectTimeout: 300 * time.Millisecond}) | |||
| 410 | + | if err != nil { | |||
| 411 | + | t.Fatal(err) | |||
| 412 | + | } | |||
| 413 | + | defer func() { _ = d.Close() }() | |||
| 414 | + | sb, _ := d.New() | |||
| 415 | + | _, err = sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 416 | + | if err == nil { | |||
| 417 | + | t.Fatal("Start succeeded") | |||
| 418 | + | } | |||
| 419 | + | if left := e.leftovers(); len(left) != 0 { | |||
| 420 | + | t.Fatalf("leftovers: %v (err %v)", left, err) | |||
| 421 | + | } | |||
| 422 | + | if d.slots.InUse() != 0 { | |||
| 423 | + | t.Fatal("slot leaked") | |||
| 424 | + | } | |||
| 425 | + | }) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func TestFailedStartIncludesContainerLogs(t *testing.T) { | |||
| 430 | + | e := newCEnv(t) | |||
| 431 | + | e.eng.exitOnRun = true | |||
| 432 | + | d := e.driver(t) | |||
| 433 | + | sb, _ := d.New() | |||
| 434 | + | _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 435 | + | if err == nil || !strings.Contains(err.Error(), "fake container log line") { | |||
| 436 | + | t.Fatalf("got %v", err) | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | func TestCleanupFailureKeepsSlotAndIsRetriedByReap(t *testing.T) { | |||
| 441 | + | e := newCEnv(t) | |||
| 442 | + | d := e.driver(t) | |||
| 443 | + | sb, _ := d.New() | |||
| 444 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 445 | + | if err != nil { | |||
| 446 | + | t.Fatal(err) | |||
| 447 | + | } | |||
| 448 | + | stub(t, conn) | |||
| 449 | + | e.eng.removeErr = errors.New("daemon busy") | |||
| 450 | + | if err := sb.Stop(context.Background()); err == nil { | |||
| 451 | + | t.Fatal("Stop must report the failed removal") | |||
| 452 | + | } | |||
| 453 | + | if d.slots.InUse() != 1 { | |||
| 454 | + | t.Fatal("the slot must stay taken while its bridge and subnet still exist") | |||
| 455 | + | } | |||
| 456 | + | e.eng.removeErr = nil | |||
| 457 | + | if err := d.Reap(context.Background()); err != nil { | |||
| 458 | + | t.Fatal(err) | |||
| 459 | + | } | |||
| 460 | + | if left := e.leftovers(); len(left) != 0 { | |||
| 461 | + | t.Fatalf("Reap left %v", left) | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func TestReapRemovesLabelledObjectsOnly(t *testing.T) { | |||
| 466 | + | e := newCEnv(t) | |||
| 467 | + | d := e.driver(t) | |||
| 468 | + | ctx := context.Background() | |||
| 469 | + | // Leftovers of a crashed run, and an unrelated user's network. | |||
| 470 | + | _, _ = e.eng.CreateContainer(ctx, containerSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}}) | |||
| 471 | + | _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "old", Labels: map[string]string{sandbox.LabelJob: "x"}}) | |||
| 472 | + | _, _ = e.eng.CreateNetwork(ctx, networkSpec{Name: "mine", Labels: map[string]string{"app": "other"}}) | |||
| 473 | + | _ = e.eng.CreateVolume(ctx, "old", map[string]string{sandbox.LabelJob: "x"}) | |||
| 474 | + | _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "er00112233"), 0o700) | |||
| 475 | + | _ = os.MkdirAll(filepath.Join(e.cfg.StateDir, "containers", "keep-me"), 0o700) | |||
| 476 | + | ||||
| 477 | + | if err := d.Reap(ctx); err != nil { | |||
| 478 | + | t.Fatal(err) | |||
| 479 | + | } | |||
| 480 | + | left := e.leftovers() | |||
| 481 | + | slices.Sort(left) | |||
| 482 | + | if len(left) != 2 || !strings.HasPrefix(left[0], "dir keep-me") || !strings.HasPrefix(left[1], "network") { | |||
| 483 | + | t.Fatalf("only the unrelated network and directory may remain: %v", left) | |||
| 484 | + | } | |||
| 485 | + | if _, err := os.Stat(filepath.Join(e.cfg.StateDir, "containers", "keep-me")); err != nil { | |||
| 486 | + | t.Fatal("Reap removed a directory that is not ours") | |||
| 487 | + | } | |||
| 488 | + | if e.plumb.Resets != 1 { | |||
| 489 | + | t.Fatal("nftables table not reset") | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | func TestCapacityAndSlotIsolation(t *testing.T) { | |||
| 494 | + | e := newCEnv(t) | |||
| 495 | + | d := e.driver(t) | |||
| 496 | + | a, _ := d.New() | |||
| 497 | + | b, _ := d.New() | |||
| 498 | + | if _, err := d.New(); !errors.Is(err, sandbox.ErrCapacity) { | |||
| 499 | + | t.Fatalf("got %v", err) | |||
| 500 | + | } | |||
| 501 | + | for _, sb := range []sandbox.Sandbox{a, b} { | |||
| 502 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 503 | + | if err != nil { | |||
| 504 | + | t.Fatal(err) | |||
| 505 | + | } | |||
| 506 | + | stub(t, conn) | |||
| 507 | + | } | |||
| 508 | + | ifaces := map[string]bool{} | |||
| 509 | + | for iface := range e.plumb.Attached { | |||
| 510 | + | ifaces[iface] = true | |||
| 511 | + | } | |||
| 512 | + | if !ifaces["erbr0"] || !ifaces["erbr1"] { | |||
| 513 | + | t.Fatalf("each job needs its own bridge: %v", ifaces) | |||
| 514 | + | } | |||
| 515 | + | _ = a.Stop(context.Background()) | |||
| 516 | + | _ = b.Stop(context.Background()) | |||
| 517 | + | if left := e.leftovers(); len(left) != 0 { | |||
| 518 | + | t.Fatalf("leftovers: %v", left) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | func TestStopWithCancelledContext(t *testing.T) { | |||
| 523 | + | e := newCEnv(t) | |||
| 524 | + | d := e.driver(t) | |||
| 525 | + | sb, _ := d.New() | |||
| 526 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 527 | + | if err != nil { | |||
| 528 | + | t.Fatal(err) | |||
| 529 | + | } | |||
| 530 | + | stub(t, conn) | |||
| 531 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 532 | + | cancel() | |||
| 533 | + | if err := sb.Stop(ctx); err != nil { | |||
| 534 | + | t.Fatal(err) | |||
| 535 | + | } | |||
| 536 | + | if left := e.leftovers(); len(left) != 0 { | |||
| 537 | + | t.Fatalf("leftovers: %v", left) | |||
| 538 | + | } | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | func TestDoubleStartRefused(t *testing.T) { | |||
| 542 | + | e := newCEnv(t) | |||
| 543 | + | d := e.driver(t) | |||
| 544 | + | sb, _ := d.New() | |||
| 545 | + | conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}) | |||
| 546 | + | if err != nil { | |||
| 547 | + | t.Fatal(err) | |||
| 548 | + | } | |||
| 549 | + | stub(t, conn) | |||
| 550 | + | if _, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "j"}); err == nil { | |||
| 551 | + | t.Fatal("second Start accepted") | |||
| 552 | + | } | |||
| 553 | + | _ = sb.Stop(context.Background()) | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | func mustPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) } | |||
A internal/sandbox/container/docker_api_test.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package container | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 15 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 16 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 17 | + | "bnema.dev/ermite-runner/internal/sandbox/sandboxtest" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // TestDockerAPIWiring runs the real Docker Engine calls (network with a named | |
| 21 | + | // bridge, labelled volume and container, bind mounts, wait, removal, reaping by | |
| 22 | + | // label) against whatever daemon DOCKER_HOST points at, including a rootless | |
| 23 | + | // one. It does not test isolation (a rootless daemon cannot run the nested | |
| 24 | + | // dockerd): the integration test in internal/sandbox/integration does. | |
| 25 | + | // | |
| 26 | + | // ERMITE_RUNNER_IT_DOCKER_API=1 ERMITE_RUNNER_TEST_IMAGE=<image> go test -run DockerAPIWiring ./internal/sandbox/container | |
| 27 | + | func TestDockerAPIWiring(t *testing.T) { | |
| 28 | + | if os.Getenv("ERMITE_RUNNER_IT_DOCKER_API") != "1" { | |
| 29 | + | t.Skip("set ERMITE_RUNNER_IT_DOCKER_API=1 (needs a reachable Docker daemon)") | |
| 30 | + | } | |
| 31 | + | image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE") | |
| 32 | + | if image == "" { | |
| 33 | + | t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to an image with /bin/sh (e.g. the runner image)") | |
| 34 | + | } | |
| 35 | + | if _, err := exec.LookPath("docker"); err != nil { | |
| 36 | + | t.Skip("docker CLI needed for the verification") | |
| 37 | + | } | |
| 38 | + | // A static test executable that plays the executor: the entrypoint of the | |
| 39 | + | // container is "<exe> init", and init runs "<exe> exec"... replaced here by a | |
| 40 | + | // tiny shell executor that speaks the stream through a Go helper binary. | |
| 41 | + | dir := t.TempDir() | |
| 42 | + | helper := filepath.Join(dir, "ermite-runner") | |
| 43 | + | build := exec.Command("go", "build", "-o", helper, "bnema.dev/ermite-runner") | |
| 44 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off") | |
| 45 | + | build.Dir = repoRoot(t) | |
| 46 | + | if out, err := build.CombinedOutput(); err != nil { | |
| 47 | + | t.Fatalf("build runner: %v\n%s", err, out) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | plumb := sandboxtest.NewPlumbing() | |
| 51 | + | cfg := sandbox.Config{ | |
| 52 | + | Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 1, Image: image, | |
| 53 | + | JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, | |
| 54 | + | Network: mustPrefix("10.213.0.0/16"), Executable: helper, DNSPort: 0, | |
| 55 | + | } | |
| 56 | + | ctx := context.Background() | |
| 57 | + | d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second}) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | defer func() { _ = d.Close() }() | |
| 62 | + | if err := d.Reap(ctx); err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for i := range 2 { | |
| 67 | + | sb, err := d.New() | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "api-wiring"}) | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatalf("job %d: %v", i, err) | |
| 74 | + | } | |
| 75 | + | var out strings.Builder | |
| 76 | + | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | if hello.Sandbox != "container" || exit.Code != 4 || !strings.HasPrefix(out.String(), "wired\n") { | |
| 81 | + | t.Fatalf("hello=%v exit=%v out=%q", hello, exit, out.String()) | |
| 82 | + | } | |
| 83 | + | // While running, Docker knows our objects by label. | |
| 84 | + | if i == 0 { | |
| 85 | + | if ids := dockerLines(t, "ps", "-aq", "--filter", "label="+sandbox.LabelJob); len(ids) != 1 { | |
| 86 | + | t.Fatalf("expected one labelled container, got %v", ids) | |
| 87 | + | } | |
| 88 | + | if n := dockerLines(t, "network", "ls", "-q", "--filter", "label="+sandbox.LabelJob); len(n) != 1 { | |
| 89 | + | t.Fatalf("expected one labelled network, got %v", n) | |
| 90 | + | } | |
| 91 | + | if v := dockerLines(t, "volume", "ls", "-q", "--filter", "label="+sandbox.LabelJob); len(v) != 1 { | |
| 92 | + | t.Fatalf("expected one labelled volume, got %v", v) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | if err := sb.Stop(ctx); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | for _, args := range [][]string{ | |
| 100 | + | {"ps", "-aq", "--filter", "label=" + sandbox.LabelJob}, | |
| 101 | + | {"network", "ls", "-q", "--filter", "label=" + sandbox.LabelJob}, | |
| 102 | + | {"volume", "ls", "-q", "--filter", "label=" + sandbox.LabelJob}, | |
| 103 | + | } { | |
| 104 | + | if l := dockerLines(t, args...); len(l) != 0 { | |
| 105 | + | t.Fatalf("docker %v left %v", args, l) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if plumb.Live() != 0 { | |
| 109 | + | t.Fatal("firewall attachments leaked") | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func dockerLines(t *testing.T, args ...string) []string { | |
| 114 | + | t.Helper() | |
| 115 | + | out, err := exec.Command("docker", args...).Output() | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatalf("docker %v: %v", args, err) | |
| 118 | + | } | |
| 119 | + | return strings.Fields(string(out)) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func repoRoot(t *testing.T) string { | |
| 123 | + | t.Helper() | |
| 124 | + | out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | return strings.TrimSpace(string(out)) | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package container | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 15 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 16 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 17 | + | "bnema.dev/ermite-runner/internal/sandbox/sandboxtest" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // TestDockerAPIWiring runs the real Docker Engine calls (network with a named | |||
| 21 | + | // bridge, labelled volume and container, bind mounts, wait, removal, reaping by | |||
| 22 | + | // label) against whatever daemon DOCKER_HOST points at, including a rootless | |||
| 23 | + | // one. It does not test isolation (a rootless daemon cannot run the nested | |||
| 24 | + | // dockerd): the integration test in internal/sandbox/integration does. | |||
| 25 | + | // | |||
| 26 | + | // ERMITE_RUNNER_IT_DOCKER_API=1 ERMITE_RUNNER_TEST_IMAGE=<image> go test -run DockerAPIWiring ./internal/sandbox/container | |||
| 27 | + | func TestDockerAPIWiring(t *testing.T) { | |||
| 28 | + | if os.Getenv("ERMITE_RUNNER_IT_DOCKER_API") != "1" { | |||
| 29 | + | t.Skip("set ERMITE_RUNNER_IT_DOCKER_API=1 (needs a reachable Docker daemon)") | |||
| 30 | + | } | |||
| 31 | + | image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE") | |||
| 32 | + | if image == "" { | |||
| 33 | + | t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to an image with /bin/sh (e.g. the runner image)") | |||
| 34 | + | } | |||
| 35 | + | if _, err := exec.LookPath("docker"); err != nil { | |||
| 36 | + | t.Skip("docker CLI needed for the verification") | |||
| 37 | + | } | |||
| 38 | + | // A static test executable that plays the executor: the entrypoint of the | |||
| 39 | + | // container is "<exe> init", and init runs "<exe> exec"... replaced here by a | |||
| 40 | + | // tiny shell executor that speaks the stream through a Go helper binary. | |||
| 41 | + | dir := t.TempDir() | |||
| 42 | + | helper := filepath.Join(dir, "ermite-runner") | |||
| 43 | + | build := exec.Command("go", "build", "-o", helper, "bnema.dev/ermite-runner") | |||
| 44 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off") | |||
| 45 | + | build.Dir = repoRoot(t) | |||
| 46 | + | if out, err := build.CombinedOutput(); err != nil { | |||
| 47 | + | t.Fatalf("build runner: %v\n%s", err, out) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | plumb := sandboxtest.NewPlumbing() | |||
| 51 | + | cfg := sandbox.Config{ | |||
| 52 | + | Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 1, Image: image, | |||
| 53 | + | JobCPUs: 1, JobMemoryMiB: 512, JobDiskMiB: 1024, UIDBase: 900000, | |||
| 54 | + | Network: mustPrefix("10.213.0.0/16"), Executable: helper, DNSPort: 0, | |||
| 55 | + | } | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second}) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | defer func() { _ = d.Close() }() | |||
| 62 | + | if err := d.Reap(ctx); err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for i := range 2 { | |||
| 67 | + | sb, err := d.New() | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "api-wiring"}) | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatalf("job %d: %v", i, err) | |||
| 74 | + | } | |||
| 75 | + | var out strings.Builder | |||
| 76 | + | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, func(o *execpb.Output) { out.Write(o.Data) }) | |||
| 77 | + | if err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | if hello.Sandbox != "container" || exit.Code != 4 || !strings.HasPrefix(out.String(), "wired\n") { | |||
| 81 | + | t.Fatalf("hello=%v exit=%v out=%q", hello, exit, out.String()) | |||
| 82 | + | } | |||
| 83 | + | // While running, Docker knows our objects by label. | |||
| 84 | + | if i == 0 { | |||
| 85 | + | if ids := dockerLines(t, "ps", "-aq", "--filter", "label="+sandbox.LabelJob); len(ids) != 1 { | |||
| 86 | + | t.Fatalf("expected one labelled container, got %v", ids) | |||
| 87 | + | } | |||
| 88 | + | if n := dockerLines(t, "network", "ls", "-q", "--filter", "label="+sandbox.LabelJob); len(n) != 1 { | |||
| 89 | + | t.Fatalf("expected one labelled network, got %v", n) | |||
| 90 | + | } | |||
| 91 | + | if v := dockerLines(t, "volume", "ls", "-q", "--filter", "label="+sandbox.LabelJob); len(v) != 1 { | |||
| 92 | + | t.Fatalf("expected one labelled volume, got %v", v) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | if err := sb.Stop(ctx); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | for _, args := range [][]string{ | |||
| 100 | + | {"ps", "-aq", "--filter", "label=" + sandbox.LabelJob}, | |||
| 101 | + | {"network", "ls", "-q", "--filter", "label=" + sandbox.LabelJob}, | |||
| 102 | + | {"volume", "ls", "-q", "--filter", "label=" + sandbox.LabelJob}, | |||
| 103 | + | } { | |||
| 104 | + | if l := dockerLines(t, args...); len(l) != 0 { | |||
| 105 | + | t.Fatalf("docker %v left %v", args, l) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | if plumb.Live() != 0 { | |||
| 109 | + | t.Fatal("firewall attachments leaked") | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func dockerLines(t *testing.T, args ...string) []string { | |||
| 114 | + | t.Helper() | |||
| 115 | + | out, err := exec.Command("docker", args...).Output() | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatalf("docker %v: %v", args, err) | |||
| 118 | + | } | |||
| 119 | + | return strings.Fields(string(out)) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func repoRoot(t *testing.T) string { | |||
| 123 | + | t.Helper() | |||
| 124 | + | out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() | |||
| 125 | + | if err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | return strings.TrimSpace(string(out)) | |||
| 129 | + | } | |||
A internal/sandbox/container/driver.go +447 -0
| @@ -0,0 +1,447 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package container is the container sandbox, for hosts without KVM: one | |
| 4 | + | // container per job with its own Docker daemon inside (the sysbox runtime when | |
| 5 | + | // the daemon lists it, else a privileged container), on its own bridge network | |
| 6 | + | // and with its own scratch volume. Everything Docker-side carries the label | |
| 7 | + | // sandbox.LabelJob so that Reap finds it after a crash. | |
| 8 | + | // | |
| 9 | + | // The executor stream is a Unix socket in a per-job directory of the runner's | |
| 10 | + | // state directory, bind-mounted into the container. The runner executable is | |
| 11 | + | // bind-mounted read-only. Both paths are host paths given to the Docker daemon: | |
| 12 | + | // the daemon must run on the same host (and mount namespace) as the runner. | |
| 13 | + | package container | |
| 14 | + | ||
| 15 | + | import ( | |
| 16 | + | "context" | |
| 17 | + | "errors" | |
| 18 | + | "fmt" | |
| 19 | + | "net" | |
| 20 | + | "net/netip" | |
| 21 | + | "os" | |
| 22 | + | "path/filepath" | |
| 23 | + | "slices" | |
| 24 | + | "sync" | |
| 25 | + | "time" | |
| 26 | + | ||
| 27 | + | "github.com/bnema/zerowrap" | |
| 28 | + | ||
| 29 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |
| 30 | + | "bnema.dev/ermite-runner/internal/netpolicy" | |
| 31 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 32 | + | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // SysboxRuntime is the name of the sysbox runtime in `docker info`. | |
| 36 | + | const SysboxRuntime = "sysbox-runc" | |
| 37 | + | ||
| 38 | + | // bridgePrefix must match the "erbr*" wildcard of the nftables policy. | |
| 39 | + | const bridgePrefix = "erbr" | |
| 40 | + | ||
| 41 | + | // Options are the testable knobs of the driver. | |
| 42 | + | type Options struct { | |
| 43 | + | Run cmdrun.Runner | |
| 44 | + | // Net overrides the network plumbing (tests). | |
| 45 | + | Net netsetup.Plumbing | |
| 46 | + | // engine overrides the Docker client (tests). | |
| 47 | + | engine engine | |
| 48 | + | // allowRootless lets tests exercise the Engine API against the rootless | |
| 49 | + | // daemon of a workstation. Never set outside tests: a nested dockerd cannot | |
| 50 | + | // work in a rootless daemon. | |
| 51 | + | allowRootless bool | |
| 52 | + | // ConnectTimeout bounds container start + init + executor connection | |
| 53 | + | // (default 90 s). | |
| 54 | + | ConnectTimeout time.Duration | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Driver implements sandbox.Driver for containers. | |
| 58 | + | type Driver struct { | |
| 59 | + | cfg sandbox.Config | |
| 60 | + | opt Options | |
| 61 | + | eng engine | |
| 62 | + | net netsetup.Plumbing | |
| 63 | + | image string | |
| 64 | + | runtime string // "" when privileged | |
| 65 | + | privileged bool | |
| 66 | + | exe string | |
| 67 | + | ||
| 68 | + | mu sync.Mutex | |
| 69 | + | slots *sandbox.Slots | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // New connects to the Docker daemon (DOCKER_HOST honoured) and checks it: not | |
| 73 | + | // rootless, the job image present (pulled if missing), the runtime decided. | |
| 74 | + | func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) { | |
| 75 | + | if opt.Run == nil { | |
| 76 | + | opt.Run = cmdrun.Exec{} | |
| 77 | + | } | |
| 78 | + | if opt.ConnectTimeout == 0 { | |
| 79 | + | opt.ConnectTimeout = 90 * time.Second | |
| 80 | + | } | |
| 81 | + | eng := opt.engine | |
| 82 | + | if eng == nil { | |
| 83 | + | e, err := newDockerEngine() | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | eng = e | |
| 88 | + | } | |
| 89 | + | d := &Driver{cfg: cfg, opt: opt, eng: eng, image: cfg.OCIImage(), slots: sandbox.NewSlots(cfg.Capacity)} | |
| 90 | + | fail := func(err error) (*Driver, error) { _ = eng.Close(); return nil, err } | |
| 91 | + | ||
| 92 | + | exe, err := cfg.ExecutablePath() | |
| 93 | + | if err != nil { | |
| 94 | + | return fail(err) | |
| 95 | + | } | |
| 96 | + | if err := sandbox.CheckStatic(exe); err != nil { | |
| 97 | + | return fail(err) | |
| 98 | + | } | |
| 99 | + | d.exe = exe | |
| 100 | + | ||
| 101 | + | info, err := eng.Info(ctx) | |
| 102 | + | if err != nil { | |
| 103 | + | return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err)) | |
| 104 | + | } | |
| 105 | + | if info.Rootless && !opt.allowRootless { | |
| 106 | + | return fail(errRootless) | |
| 107 | + | } | |
| 108 | + | if info.OSType != "" && info.OSType != "linux" { | |
| 109 | + | return fail(fmt.Errorf("the Docker daemon runs %s containers, need linux", info.OSType)) | |
| 110 | + | } | |
| 111 | + | log := zerowrap.FromCtx(ctx) | |
| 112 | + | if slices.Contains(info.Runtimes, SysboxRuntime) { | |
| 113 | + | d.runtime = SysboxRuntime | |
| 114 | + | log.Info().Str("runtime", SysboxRuntime).Msg("container isolation: using the sysbox runtime") | |
| 115 | + | } else { | |
| 116 | + | d.privileged = true | |
| 117 | + | log.Warn().Msg("container isolation: sysbox-runc not available, jobs run in PRIVILEGED containers, which is host-root equivalent; their network policy is best effort. Prefer vm isolation or install sysbox") | |
| 118 | + | } | |
| 119 | + | if err := eng.EnsureImage(ctx, d.image); err != nil { | |
| 120 | + | return fail(err) | |
| 121 | + | } | |
| 122 | + | d.net = opt.Net | |
| 123 | + | if d.net == nil { | |
| 124 | + | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | |
| 125 | + | } | |
| 126 | + | if err := d.net.EnableForwarding(); err != nil { | |
| 127 | + | return fail(err) | |
| 128 | + | } | |
| 129 | + | return d, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Mode implements sandbox.Driver. | |
| 133 | + | func (d *Driver) Mode() sandbox.Mode { return sandbox.Container } | |
| 134 | + | ||
| 135 | + | // Privileged reports whether jobs run privileged (no sysbox). | |
| 136 | + | func (d *Driver) Privileged() bool { return d.privileged } | |
| 137 | + | ||
| 138 | + | // Close implements sandbox.Driver. | |
| 139 | + | func (d *Driver) Close() error { | |
| 140 | + | if d.net != nil { | |
| 141 | + | d.net.Close() | |
| 142 | + | } | |
| 143 | + | return d.eng.Close() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // New implements sandbox.Driver. | |
| 147 | + | func (d *Driver) New() (sandbox.Sandbox, error) { | |
| 148 | + | d.mu.Lock() | |
| 149 | + | defer d.mu.Unlock() | |
| 150 | + | slot, err := d.slots.Acquire() | |
| 151 | + | if err != nil { | |
| 152 | + | return nil, err | |
| 153 | + | } | |
| 154 | + | return &containerSandbox{d: d, slot: slot}, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (d *Driver) release(slot int) { | |
| 158 | + | d.mu.Lock() | |
| 159 | + | d.slots.Release(slot) | |
| 160 | + | d.mu.Unlock() | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (d *Driver) runDir(id string) string { return filepath.Join(d.cfg.StateDir, "containers", id) } | |
| 164 | + | ||
| 165 | + | type containerSandbox struct { | |
| 166 | + | d *Driver | |
| 167 | + | slot int | |
| 168 | + | ||
| 169 | + | mu sync.Mutex | |
| 170 | + | started bool | |
| 171 | + | stopped bool | |
| 172 | + | id string | |
| 173 | + | netID string | |
| 174 | + | volume string | |
| 175 | + | container string | |
| 176 | + | listener net.Listener | |
| 177 | + | conn net.Conn | |
| 178 | + | iface string | |
| 179 | + | gateway netip.Addr | |
| 180 | + | attached bool | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // Start implements sandbox.Sandbox. | |
| 184 | + | func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) { | |
| 185 | + | s.mu.Lock() | |
| 186 | + | defer s.mu.Unlock() | |
| 187 | + | if s.started || s.stopped { | |
| 188 | + | return nil, errors.New("container sandbox: Start called twice or after Stop") | |
| 189 | + | } | |
| 190 | + | s.started = true | |
| 191 | + | d := s.d | |
| 192 | + | log := zerowrap.FromCtx(ctx) | |
| 193 | + | spec = d.cfg.Resolve(spec) | |
| 194 | + | ||
| 195 | + | id, err := sandbox.NewID() | |
| 196 | + | if err != nil { | |
| 197 | + | s.releaseLocked() | |
| 198 | + | return nil, err | |
| 199 | + | } | |
| 200 | + | s.id = id | |
| 201 | + | conn, err := s.run(ctx, spec) | |
| 202 | + | if err != nil { | |
| 203 | + | var tail string | |
| 204 | + | if s.container != "" { | |
| 205 | + | tail, _ = d.eng.LogsTail(context.WithoutCancel(ctx), s.container, 40) | |
| 206 | + | } | |
| 207 | + | cerr := s.cleanupLocked(context.WithoutCancel(ctx)) | |
| 208 | + | if tail != "" { | |
| 209 | + | err = fmt.Errorf("%w\ncontainer log tail:\n%s", err, tail) | |
| 210 | + | } | |
| 211 | + | return nil, errors.Join(err, cerr) | |
| 212 | + | } | |
| 213 | + | log.Info().Str("container", s.container).Int("slot", s.slot).Str("job", spec.JobID).Bool("privileged", d.privileged).Msg("container running") | |
| 214 | + | return conn, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (s *containerSandbox) run(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) { | |
| 218 | + | d := s.d | |
| 219 | + | netw, err := netpolicy.Subnet(d.cfg.Network, s.slot) | |
| 220 | + | if err != nil { | |
| 221 | + | return nil, err | |
| 222 | + | } | |
| 223 | + | labels := map[string]string{sandbox.LabelJob: spec.JobID} | |
| 224 | + | name := "ermite-" + s.id | |
| 225 | + | ||
| 226 | + | // Per-job directory with the executor socket. It is created before anything | |
| 227 | + | // on the Docker side so that a crash leaves a recognisable directory. | |
| 228 | + | dir := d.runDir(s.id) | |
| 229 | + | if err := os.MkdirAll(dir, 0o711); err != nil { | |
| 230 | + | return nil, err | |
| 231 | + | } | |
| 232 | + | if err := os.Chmod(dir, 0o711); err != nil { | |
| 233 | + | return nil, err | |
| 234 | + | } | |
| 235 | + | sock := filepath.Join(dir, sandbox.ContainerSocket) | |
| 236 | + | ln, err := net.Listen("unix", sock) | |
| 237 | + | if err != nil { | |
| 238 | + | return nil, fmt.Errorf("listen executor socket: %w", err) | |
| 239 | + | } | |
| 240 | + | s.listener = ln | |
| 241 | + | // The container's root may be a mapped (sysbox) user: it must be able to | |
| 242 | + | // connect. The path is private to the job (random id, 0711 directory). | |
| 243 | + | if err := os.Chmod(sock, 0o666); err != nil { | |
| 244 | + | return nil, err | |
| 245 | + | } | |
| 246 | + | accepted := make(chan struct { | |
| 247 | + | c net.Conn | |
| 248 | + | err error | |
| 249 | + | }, 1) | |
| 250 | + | go func() { | |
| 251 | + | c, err := ln.Accept() | |
| 252 | + | accepted <- struct { | |
| 253 | + | c net.Conn | |
| 254 | + | err error | |
| 255 | + | }{c, err} | |
| 256 | + | }() | |
| 257 | + | ||
| 258 | + | s.iface = fmt.Sprintf("%s%d", bridgePrefix, s.slot) | |
| 259 | + | s.gateway = netw.Gateway | |
| 260 | + | netID, err := d.eng.CreateNetwork(ctx, networkSpec{Name: name, Bridge: s.iface, Subnet: netw.Prefix, Gateway: netw.Gateway, Labels: labels}) | |
| 261 | + | if err != nil { | |
| 262 | + | return nil, err | |
| 263 | + | } | |
| 264 | + | s.netID = netID | |
| 265 | + | s.attached = true | |
| 266 | + | if err := d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest}); err != nil { | |
| 267 | + | return nil, err | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | s.volume = name | |
| 271 | + | if err := d.eng.CreateVolume(ctx, name, labels); err != nil { | |
| 272 | + | s.volume = "" | |
| 273 | + | return nil, fmt.Errorf("create volume: %w", err) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | entry := sandbox.GuestExecDir + "/" + sandbox.GuestExecName | |
| 277 | + | cid, err := d.eng.CreateContainer(ctx, containerSpec{ | |
| 278 | + | Name: name, | |
| 279 | + | Image: d.image, | |
| 280 | + | Entrypoint: []string{entry, "init"}, | |
| 281 | + | Env: []string{"ERMITE_CONNECT=unix:" + sandbox.ContainerRunDir + "/" + sandbox.ContainerSocket}, | |
| 282 | + | Labels: labels, | |
| 283 | + | Runtime: d.runtime, | |
| 284 | + | Privileged: d.privileged, | |
| 285 | + | Network: name, | |
| 286 | + | IPv4: netw.Guest, | |
| 287 | + | DNS: netw.Gateway, | |
| 288 | + | Binds: []bindMount{ | |
| 289 | + | {Source: d.exe, Target: entry, ReadOnly: true}, | |
| 290 | + | {Source: dir, Target: sandbox.ContainerRunDir}, | |
| 291 | + | }, | |
| 292 | + | Volumes: []volumeMount{{Name: name, Target: sandbox.GuestScratch}}, | |
| 293 | + | MemoryMiB: spec.MemoryMiB, | |
| 294 | + | CPUs: spec.CPUs, | |
| 295 | + | }) | |
| 296 | + | if err != nil { | |
| 297 | + | return nil, err | |
| 298 | + | } | |
| 299 | + | s.container = cid | |
| 300 | + | exited := d.eng.WaitExit(ctx, cid) | |
| 301 | + | if err := d.eng.StartContainer(ctx, cid); err != nil { | |
| 302 | + | return nil, fmt.Errorf("start container: %w", err) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | timer := time.NewTimer(d.opt.ConnectTimeout) | |
| 306 | + | defer timer.Stop() | |
| 307 | + | select { | |
| 308 | + | case r := <-accepted: | |
| 309 | + | if r.err != nil { | |
| 310 | + | return nil, fmt.Errorf("accept executor connection: %w", r.err) | |
| 311 | + | } | |
| 312 | + | s.conn = r.c | |
| 313 | + | return r.c, nil | |
| 314 | + | case ex := <-exited: | |
| 315 | + | return nil, fmt.Errorf("container exited (code %d) before the executor connected", ex.Code) | |
| 316 | + | case <-timer.C: | |
| 317 | + | return nil, fmt.Errorf("executor did not connect within %s", d.opt.ConnectTimeout) | |
| 318 | + | case <-ctx.Done(): | |
| 319 | + | return nil, ctx.Err() | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // Stop implements sandbox.Sandbox. | |
| 324 | + | func (s *containerSandbox) Stop(ctx context.Context) error { | |
| 325 | + | s.mu.Lock() | |
| 326 | + | defer s.mu.Unlock() | |
| 327 | + | if s.stopped { | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | if !s.started { | |
| 331 | + | s.releaseLocked() | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | return s.cleanupLocked(context.WithoutCancel(ctx)) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (s *containerSandbox) releaseLocked() { | |
| 338 | + | s.stopped = true | |
| 339 | + | s.d.release(s.slot) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // cleanupLocked removes the container, then its network, volume, firewall rules | |
| 343 | + | // and directory, continuing after errors. The slot is released only when | |
| 344 | + | // everything is gone (so its bridge name and subnet are not reused while the | |
| 345 | + | // old ones still exist); leftovers are reaped by label at the next start. | |
| 346 | + | func (s *containerSandbox) cleanupLocked(ctx context.Context) error { | |
| 347 | + | d := s.d | |
| 348 | + | var errs []error | |
| 349 | + | if s.conn != nil { | |
| 350 | + | _ = s.conn.Close() | |
| 351 | + | s.conn = nil | |
| 352 | + | } | |
| 353 | + | if s.listener != nil { | |
| 354 | + | _ = s.listener.Close() | |
| 355 | + | s.listener = nil | |
| 356 | + | } | |
| 357 | + | if s.container != "" { | |
| 358 | + | if err := d.eng.RemoveContainer(ctx, s.container); err != nil { | |
| 359 | + | errs = append(errs, fmt.Errorf("remove container: %w", err)) | |
| 360 | + | } else { | |
| 361 | + | s.container = "" | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | if s.attached { | |
| 365 | + | if err := d.net.Detach(ctx, s.iface, s.gateway); err != nil { | |
| 366 | + | errs = append(errs, err) | |
| 367 | + | } else { | |
| 368 | + | s.attached = false | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | if s.netID != "" && s.container == "" { | |
| 372 | + | if err := d.eng.RemoveNetwork(ctx, s.netID); err != nil { | |
| 373 | + | errs = append(errs, fmt.Errorf("remove network: %w", err)) | |
| 374 | + | } else { | |
| 375 | + | s.netID = "" | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if s.volume != "" && s.container == "" { | |
| 379 | + | if err := d.eng.RemoveVolume(ctx, s.volume); err != nil { | |
| 380 | + | errs = append(errs, fmt.Errorf("remove volume: %w", err)) | |
| 381 | + | } else { | |
| 382 | + | s.volume = "" | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | if err := os.RemoveAll(d.runDir(s.id)); err != nil { | |
| 386 | + | errs = append(errs, err) | |
| 387 | + | } | |
| 388 | + | if len(errs) == 0 { | |
| 389 | + | s.releaseLocked() | |
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | s.stopped = true | |
| 393 | + | err := errors.Join(errs...) | |
| 394 | + | log := zerowrap.FromCtx(ctx) | |
| 395 | + | log.Error().Err(err).Str("job", s.id).Msg("container cleanup incomplete; the next runner start will retry") | |
| 396 | + | return err | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // Reap implements sandbox.Driver: remove every container, network and volume | |
| 400 | + | // carrying the job label, the nftables table, and the per-job directories. | |
| 401 | + | func (d *Driver) Reap(ctx context.Context) error { | |
| 402 | + | log := zerowrap.FromCtx(ctx) | |
| 403 | + | var errs []error | |
| 404 | + | ids, err := d.eng.ListContainers(ctx, sandbox.LabelJob) | |
| 405 | + | if err != nil { | |
| 406 | + | errs = append(errs, fmt.Errorf("list containers: %w", err)) | |
| 407 | + | } | |
| 408 | + | for _, id := range ids { | |
| 409 | + | log.Warn().Str("container", id).Msg("removing leftover job container") | |
| 410 | + | if err := d.eng.RemoveContainer(ctx, id); err != nil { | |
| 411 | + | errs = append(errs, err) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | nets, err := d.eng.ListNetworks(ctx, sandbox.LabelJob) | |
| 415 | + | if err != nil { | |
| 416 | + | errs = append(errs, fmt.Errorf("list networks: %w", err)) | |
| 417 | + | } | |
| 418 | + | for _, n := range nets { | |
| 419 | + | log.Warn().Str("network", n).Msg("removing leftover job network") | |
| 420 | + | if err := d.eng.RemoveNetwork(ctx, n); err != nil { | |
| 421 | + | errs = append(errs, err) | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | vols, err := d.eng.ListVolumes(ctx, sandbox.LabelJob) | |
| 425 | + | if err != nil { | |
| 426 | + | errs = append(errs, fmt.Errorf("list volumes: %w", err)) | |
| 427 | + | } | |
| 428 | + | for _, v := range vols { | |
| 429 | + | log.Warn().Str("volume", v).Msg("removing leftover job volume") | |
| 430 | + | if err := d.eng.RemoveVolume(ctx, v); err != nil { | |
| 431 | + | errs = append(errs, err) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | if err := d.net.Reset(ctx); err != nil { | |
| 435 | + | errs = append(errs, fmt.Errorf("reset nftables table: %w", err)) | |
| 436 | + | } | |
| 437 | + | root := filepath.Join(d.cfg.StateDir, "containers") | |
| 438 | + | ents, _ := os.ReadDir(root) | |
| 439 | + | for _, e := range ents { | |
| 440 | + | if sandbox.ValidID(e.Name()) { | |
| 441 | + | if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil { | |
| 442 | + | errs = append(errs, err) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | return errors.Join(errs...) | |
| 447 | + | } | |
| @@ -0,0 +1,447 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package container is the container sandbox, for hosts without KVM: one | |||
| 4 | + | // container per job with its own Docker daemon inside (the sysbox runtime when | |||
| 5 | + | // the daemon lists it, else a privileged container), on its own bridge network | |||
| 6 | + | // and with its own scratch volume. Everything Docker-side carries the label | |||
| 7 | + | // sandbox.LabelJob so that Reap finds it after a crash. | |||
| 8 | + | // | |||
| 9 | + | // The executor stream is a Unix socket in a per-job directory of the runner's | |||
| 10 | + | // state directory, bind-mounted into the container. The runner executable is | |||
| 11 | + | // bind-mounted read-only. Both paths are host paths given to the Docker daemon: | |||
| 12 | + | // the daemon must run on the same host (and mount namespace) as the runner. | |||
| 13 | + | package container | |||
| 14 | + | ||||
| 15 | + | import ( | |||
| 16 | + | "context" | |||
| 17 | + | "errors" | |||
| 18 | + | "fmt" | |||
| 19 | + | "net" | |||
| 20 | + | "net/netip" | |||
| 21 | + | "os" | |||
| 22 | + | "path/filepath" | |||
| 23 | + | "slices" | |||
| 24 | + | "sync" | |||
| 25 | + | "time" | |||
| 26 | + | ||||
| 27 | + | "github.com/bnema/zerowrap" | |||
| 28 | + | ||||
| 29 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |||
| 30 | + | "bnema.dev/ermite-runner/internal/netpolicy" | |||
| 31 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 32 | + | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // SysboxRuntime is the name of the sysbox runtime in `docker info`. | |||
| 36 | + | const SysboxRuntime = "sysbox-runc" | |||
| 37 | + | ||||
| 38 | + | // bridgePrefix must match the "erbr*" wildcard of the nftables policy. | |||
| 39 | + | const bridgePrefix = "erbr" | |||
| 40 | + | ||||
| 41 | + | // Options are the testable knobs of the driver. | |||
| 42 | + | type Options struct { | |||
| 43 | + | Run cmdrun.Runner | |||
| 44 | + | // Net overrides the network plumbing (tests). | |||
| 45 | + | Net netsetup.Plumbing | |||
| 46 | + | // engine overrides the Docker client (tests). | |||
| 47 | + | engine engine | |||
| 48 | + | // allowRootless lets tests exercise the Engine API against the rootless | |||
| 49 | + | // daemon of a workstation. Never set outside tests: a nested dockerd cannot | |||
| 50 | + | // work in a rootless daemon. | |||
| 51 | + | allowRootless bool | |||
| 52 | + | // ConnectTimeout bounds container start + init + executor connection | |||
| 53 | + | // (default 90 s). | |||
| 54 | + | ConnectTimeout time.Duration | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Driver implements sandbox.Driver for containers. | |||
| 58 | + | type Driver struct { | |||
| 59 | + | cfg sandbox.Config | |||
| 60 | + | opt Options | |||
| 61 | + | eng engine | |||
| 62 | + | net netsetup.Plumbing | |||
| 63 | + | image string | |||
| 64 | + | runtime string // "" when privileged | |||
| 65 | + | privileged bool | |||
| 66 | + | exe string | |||
| 67 | + | ||||
| 68 | + | mu sync.Mutex | |||
| 69 | + | slots *sandbox.Slots | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // New connects to the Docker daemon (DOCKER_HOST honoured) and checks it: not | |||
| 73 | + | // rootless, the job image present (pulled if missing), the runtime decided. | |||
| 74 | + | func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) { | |||
| 75 | + | if opt.Run == nil { | |||
| 76 | + | opt.Run = cmdrun.Exec{} | |||
| 77 | + | } | |||
| 78 | + | if opt.ConnectTimeout == 0 { | |||
| 79 | + | opt.ConnectTimeout = 90 * time.Second | |||
| 80 | + | } | |||
| 81 | + | eng := opt.engine | |||
| 82 | + | if eng == nil { | |||
| 83 | + | e, err := newDockerEngine() | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, err | |||
| 86 | + | } | |||
| 87 | + | eng = e | |||
| 88 | + | } | |||
| 89 | + | d := &Driver{cfg: cfg, opt: opt, eng: eng, image: cfg.OCIImage(), slots: sandbox.NewSlots(cfg.Capacity)} | |||
| 90 | + | fail := func(err error) (*Driver, error) { _ = eng.Close(); return nil, err } | |||
| 91 | + | ||||
| 92 | + | exe, err := cfg.ExecutablePath() | |||
| 93 | + | if err != nil { | |||
| 94 | + | return fail(err) | |||
| 95 | + | } | |||
| 96 | + | if err := sandbox.CheckStatic(exe); err != nil { | |||
| 97 | + | return fail(err) | |||
| 98 | + | } | |||
| 99 | + | d.exe = exe | |||
| 100 | + | ||||
| 101 | + | info, err := eng.Info(ctx) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err)) | |||
| 104 | + | } | |||
| 105 | + | if info.Rootless && !opt.allowRootless { | |||
| 106 | + | return fail(errRootless) | |||
| 107 | + | } | |||
| 108 | + | if info.OSType != "" && info.OSType != "linux" { | |||
| 109 | + | return fail(fmt.Errorf("the Docker daemon runs %s containers, need linux", info.OSType)) | |||
| 110 | + | } | |||
| 111 | + | log := zerowrap.FromCtx(ctx) | |||
| 112 | + | if slices.Contains(info.Runtimes, SysboxRuntime) { | |||
| 113 | + | d.runtime = SysboxRuntime | |||
| 114 | + | log.Info().Str("runtime", SysboxRuntime).Msg("container isolation: using the sysbox runtime") | |||
| 115 | + | } else { | |||
| 116 | + | d.privileged = true | |||
| 117 | + | log.Warn().Msg("container isolation: sysbox-runc not available, jobs run in PRIVILEGED containers, which is host-root equivalent; their network policy is best effort. Prefer vm isolation or install sysbox") | |||
| 118 | + | } | |||
| 119 | + | if err := eng.EnsureImage(ctx, d.image); err != nil { | |||
| 120 | + | return fail(err) | |||
| 121 | + | } | |||
| 122 | + | d.net = opt.Net | |||
| 123 | + | if d.net == nil { | |||
| 124 | + | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | |||
| 125 | + | } | |||
| 126 | + | if err := d.net.EnableForwarding(); err != nil { | |||
| 127 | + | return fail(err) | |||
| 128 | + | } | |||
| 129 | + | return d, nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Mode implements sandbox.Driver. | |||
| 133 | + | func (d *Driver) Mode() sandbox.Mode { return sandbox.Container } | |||
| 134 | + | ||||
| 135 | + | // Privileged reports whether jobs run privileged (no sysbox). | |||
| 136 | + | func (d *Driver) Privileged() bool { return d.privileged } | |||
| 137 | + | ||||
| 138 | + | // Close implements sandbox.Driver. | |||
| 139 | + | func (d *Driver) Close() error { | |||
| 140 | + | if d.net != nil { | |||
| 141 | + | d.net.Close() | |||
| 142 | + | } | |||
| 143 | + | return d.eng.Close() | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // New implements sandbox.Driver. | |||
| 147 | + | func (d *Driver) New() (sandbox.Sandbox, error) { | |||
| 148 | + | d.mu.Lock() | |||
| 149 | + | defer d.mu.Unlock() | |||
| 150 | + | slot, err := d.slots.Acquire() | |||
| 151 | + | if err != nil { | |||
| 152 | + | return nil, err | |||
| 153 | + | } | |||
| 154 | + | return &containerSandbox{d: d, slot: slot}, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (d *Driver) release(slot int) { | |||
| 158 | + | d.mu.Lock() | |||
| 159 | + | d.slots.Release(slot) | |||
| 160 | + | d.mu.Unlock() | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (d *Driver) runDir(id string) string { return filepath.Join(d.cfg.StateDir, "containers", id) } | |||
| 164 | + | ||||
| 165 | + | type containerSandbox struct { | |||
| 166 | + | d *Driver | |||
| 167 | + | slot int | |||
| 168 | + | ||||
| 169 | + | mu sync.Mutex | |||
| 170 | + | started bool | |||
| 171 | + | stopped bool | |||
| 172 | + | id string | |||
| 173 | + | netID string | |||
| 174 | + | volume string | |||
| 175 | + | container string | |||
| 176 | + | listener net.Listener | |||
| 177 | + | conn net.Conn | |||
| 178 | + | iface string | |||
| 179 | + | gateway netip.Addr | |||
| 180 | + | attached bool | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Start implements sandbox.Sandbox. | |||
| 184 | + | func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) { | |||
| 185 | + | s.mu.Lock() | |||
| 186 | + | defer s.mu.Unlock() | |||
| 187 | + | if s.started || s.stopped { | |||
| 188 | + | return nil, errors.New("container sandbox: Start called twice or after Stop") | |||
| 189 | + | } | |||
| 190 | + | s.started = true | |||
| 191 | + | d := s.d | |||
| 192 | + | log := zerowrap.FromCtx(ctx) | |||
| 193 | + | spec = d.cfg.Resolve(spec) | |||
| 194 | + | ||||
| 195 | + | id, err := sandbox.NewID() | |||
| 196 | + | if err != nil { | |||
| 197 | + | s.releaseLocked() | |||
| 198 | + | return nil, err | |||
| 199 | + | } | |||
| 200 | + | s.id = id | |||
| 201 | + | conn, err := s.run(ctx, spec) | |||
| 202 | + | if err != nil { | |||
| 203 | + | var tail string | |||
| 204 | + | if s.container != "" { | |||
| 205 | + | tail, _ = d.eng.LogsTail(context.WithoutCancel(ctx), s.container, 40) | |||
| 206 | + | } | |||
| 207 | + | cerr := s.cleanupLocked(context.WithoutCancel(ctx)) | |||
| 208 | + | if tail != "" { | |||
| 209 | + | err = fmt.Errorf("%w\ncontainer log tail:\n%s", err, tail) | |||
| 210 | + | } | |||
| 211 | + | return nil, errors.Join(err, cerr) | |||
| 212 | + | } | |||
| 213 | + | log.Info().Str("container", s.container).Int("slot", s.slot).Str("job", spec.JobID).Bool("privileged", d.privileged).Msg("container running") | |||
| 214 | + | return conn, nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (s *containerSandbox) run(ctx context.Context, spec sandbox.JobSpec) (sandbox.Conn, error) { | |||
| 218 | + | d := s.d | |||
| 219 | + | netw, err := netpolicy.Subnet(d.cfg.Network, s.slot) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return nil, err | |||
| 222 | + | } | |||
| 223 | + | labels := map[string]string{sandbox.LabelJob: spec.JobID} | |||
| 224 | + | name := "ermite-" + s.id | |||
| 225 | + | ||||
| 226 | + | // Per-job directory with the executor socket. It is created before anything | |||
| 227 | + | // on the Docker side so that a crash leaves a recognisable directory. | |||
| 228 | + | dir := d.runDir(s.id) | |||
| 229 | + | if err := os.MkdirAll(dir, 0o711); err != nil { | |||
| 230 | + | return nil, err | |||
| 231 | + | } | |||
| 232 | + | if err := os.Chmod(dir, 0o711); err != nil { | |||
| 233 | + | return nil, err | |||
| 234 | + | } | |||
| 235 | + | sock := filepath.Join(dir, sandbox.ContainerSocket) | |||
| 236 | + | ln, err := net.Listen("unix", sock) | |||
| 237 | + | if err != nil { | |||
| 238 | + | return nil, fmt.Errorf("listen executor socket: %w", err) | |||
| 239 | + | } | |||
| 240 | + | s.listener = ln | |||
| 241 | + | // The container's root may be a mapped (sysbox) user: it must be able to | |||
| 242 | + | // connect. The path is private to the job (random id, 0711 directory). | |||
| 243 | + | if err := os.Chmod(sock, 0o666); err != nil { | |||
| 244 | + | return nil, err | |||
| 245 | + | } | |||
| 246 | + | accepted := make(chan struct { | |||
| 247 | + | c net.Conn | |||
| 248 | + | err error | |||
| 249 | + | }, 1) | |||
| 250 | + | go func() { | |||
| 251 | + | c, err := ln.Accept() | |||
| 252 | + | accepted <- struct { | |||
| 253 | + | c net.Conn | |||
| 254 | + | err error | |||
| 255 | + | }{c, err} | |||
| 256 | + | }() | |||
| 257 | + | ||||
| 258 | + | s.iface = fmt.Sprintf("%s%d", bridgePrefix, s.slot) | |||
| 259 | + | s.gateway = netw.Gateway | |||
| 260 | + | netID, err := d.eng.CreateNetwork(ctx, networkSpec{Name: name, Bridge: s.iface, Subnet: netw.Prefix, Gateway: netw.Gateway, Labels: labels}) | |||
| 261 | + | if err != nil { | |||
| 262 | + | return nil, err | |||
| 263 | + | } | |||
| 264 | + | s.netID = netID | |||
| 265 | + | s.attached = true | |||
| 266 | + | if err := d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest}); err != nil { | |||
| 267 | + | return nil, err | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | s.volume = name | |||
| 271 | + | if err := d.eng.CreateVolume(ctx, name, labels); err != nil { | |||
| 272 | + | s.volume = "" | |||
| 273 | + | return nil, fmt.Errorf("create volume: %w", err) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | entry := sandbox.GuestExecDir + "/" + sandbox.GuestExecName | |||
| 277 | + | cid, err := d.eng.CreateContainer(ctx, containerSpec{ | |||
| 278 | + | Name: name, | |||
| 279 | + | Image: d.image, | |||
| 280 | + | Entrypoint: []string{entry, "init"}, | |||
| 281 | + | Env: []string{"ERMITE_CONNECT=unix:" + sandbox.ContainerRunDir + "/" + sandbox.ContainerSocket}, | |||
| 282 | + | Labels: labels, | |||
| 283 | + | Runtime: d.runtime, | |||
| 284 | + | Privileged: d.privileged, | |||
| 285 | + | Network: name, | |||
| 286 | + | IPv4: netw.Guest, | |||
| 287 | + | DNS: netw.Gateway, | |||
| 288 | + | Binds: []bindMount{ | |||
| 289 | + | {Source: d.exe, Target: entry, ReadOnly: true}, | |||
| 290 | + | {Source: dir, Target: sandbox.ContainerRunDir}, | |||
| 291 | + | }, | |||
| 292 | + | Volumes: []volumeMount{{Name: name, Target: sandbox.GuestScratch}}, | |||
| 293 | + | MemoryMiB: spec.MemoryMiB, | |||
| 294 | + | CPUs: spec.CPUs, | |||
| 295 | + | }) | |||
| 296 | + | if err != nil { | |||
| 297 | + | return nil, err | |||
| 298 | + | } | |||
| 299 | + | s.container = cid | |||
| 300 | + | exited := d.eng.WaitExit(ctx, cid) | |||
| 301 | + | if err := d.eng.StartContainer(ctx, cid); err != nil { | |||
| 302 | + | return nil, fmt.Errorf("start container: %w", err) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | timer := time.NewTimer(d.opt.ConnectTimeout) | |||
| 306 | + | defer timer.Stop() | |||
| 307 | + | select { | |||
| 308 | + | case r := <-accepted: | |||
| 309 | + | if r.err != nil { | |||
| 310 | + | return nil, fmt.Errorf("accept executor connection: %w", r.err) | |||
| 311 | + | } | |||
| 312 | + | s.conn = r.c | |||
| 313 | + | return r.c, nil | |||
| 314 | + | case ex := <-exited: | |||
| 315 | + | return nil, fmt.Errorf("container exited (code %d) before the executor connected", ex.Code) | |||
| 316 | + | case <-timer.C: | |||
| 317 | + | return nil, fmt.Errorf("executor did not connect within %s", d.opt.ConnectTimeout) | |||
| 318 | + | case <-ctx.Done(): | |||
| 319 | + | return nil, ctx.Err() | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // Stop implements sandbox.Sandbox. | |||
| 324 | + | func (s *containerSandbox) Stop(ctx context.Context) error { | |||
| 325 | + | s.mu.Lock() | |||
| 326 | + | defer s.mu.Unlock() | |||
| 327 | + | if s.stopped { | |||
| 328 | + | return nil | |||
| 329 | + | } | |||
| 330 | + | if !s.started { | |||
| 331 | + | s.releaseLocked() | |||
| 332 | + | return nil | |||
| 333 | + | } | |||
| 334 | + | return s.cleanupLocked(context.WithoutCancel(ctx)) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func (s *containerSandbox) releaseLocked() { | |||
| 338 | + | s.stopped = true | |||
| 339 | + | s.d.release(s.slot) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // cleanupLocked removes the container, then its network, volume, firewall rules | |||
| 343 | + | // and directory, continuing after errors. The slot is released only when | |||
| 344 | + | // everything is gone (so its bridge name and subnet are not reused while the | |||
| 345 | + | // old ones still exist); leftovers are reaped by label at the next start. | |||
| 346 | + | func (s *containerSandbox) cleanupLocked(ctx context.Context) error { | |||
| 347 | + | d := s.d | |||
| 348 | + | var errs []error | |||
| 349 | + | if s.conn != nil { | |||
| 350 | + | _ = s.conn.Close() | |||
| 351 | + | s.conn = nil | |||
| 352 | + | } | |||
| 353 | + | if s.listener != nil { | |||
| 354 | + | _ = s.listener.Close() | |||
| 355 | + | s.listener = nil | |||
| 356 | + | } | |||
| 357 | + | if s.container != "" { | |||
| 358 | + | if err := d.eng.RemoveContainer(ctx, s.container); err != nil { | |||
| 359 | + | errs = append(errs, fmt.Errorf("remove container: %w", err)) | |||
| 360 | + | } else { | |||
| 361 | + | s.container = "" | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | if s.attached { | |||
| 365 | + | if err := d.net.Detach(ctx, s.iface, s.gateway); err != nil { | |||
| 366 | + | errs = append(errs, err) | |||
| 367 | + | } else { | |||
| 368 | + | s.attached = false | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | if s.netID != "" && s.container == "" { | |||
| 372 | + | if err := d.eng.RemoveNetwork(ctx, s.netID); err != nil { | |||
| 373 | + | errs = append(errs, fmt.Errorf("remove network: %w", err)) | |||
| 374 | + | } else { | |||
| 375 | + | s.netID = "" | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | if s.volume != "" && s.container == "" { | |||
| 379 | + | if err := d.eng.RemoveVolume(ctx, s.volume); err != nil { | |||
| 380 | + | errs = append(errs, fmt.Errorf("remove volume: %w", err)) | |||
| 381 | + | } else { | |||
| 382 | + | s.volume = "" | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | if err := os.RemoveAll(d.runDir(s.id)); err != nil { | |||
| 386 | + | errs = append(errs, err) | |||
| 387 | + | } | |||
| 388 | + | if len(errs) == 0 { | |||
| 389 | + | s.releaseLocked() | |||
| 390 | + | return nil | |||
| 391 | + | } | |||
| 392 | + | s.stopped = true | |||
| 393 | + | err := errors.Join(errs...) | |||
| 394 | + | log := zerowrap.FromCtx(ctx) | |||
| 395 | + | log.Error().Err(err).Str("job", s.id).Msg("container cleanup incomplete; the next runner start will retry") | |||
| 396 | + | return err | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // Reap implements sandbox.Driver: remove every container, network and volume | |||
| 400 | + | // carrying the job label, the nftables table, and the per-job directories. | |||
| 401 | + | func (d *Driver) Reap(ctx context.Context) error { | |||
| 402 | + | log := zerowrap.FromCtx(ctx) | |||
| 403 | + | var errs []error | |||
| 404 | + | ids, err := d.eng.ListContainers(ctx, sandbox.LabelJob) | |||
| 405 | + | if err != nil { | |||
| 406 | + | errs = append(errs, fmt.Errorf("list containers: %w", err)) | |||
| 407 | + | } | |||
| 408 | + | for _, id := range ids { | |||
| 409 | + | log.Warn().Str("container", id).Msg("removing leftover job container") | |||
| 410 | + | if err := d.eng.RemoveContainer(ctx, id); err != nil { | |||
| 411 | + | errs = append(errs, err) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | nets, err := d.eng.ListNetworks(ctx, sandbox.LabelJob) | |||
| 415 | + | if err != nil { | |||
| 416 | + | errs = append(errs, fmt.Errorf("list networks: %w", err)) | |||
| 417 | + | } | |||
| 418 | + | for _, n := range nets { | |||
| 419 | + | log.Warn().Str("network", n).Msg("removing leftover job network") | |||
| 420 | + | if err := d.eng.RemoveNetwork(ctx, n); err != nil { | |||
| 421 | + | errs = append(errs, err) | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | vols, err := d.eng.ListVolumes(ctx, sandbox.LabelJob) | |||
| 425 | + | if err != nil { | |||
| 426 | + | errs = append(errs, fmt.Errorf("list volumes: %w", err)) | |||
| 427 | + | } | |||
| 428 | + | for _, v := range vols { | |||
| 429 | + | log.Warn().Str("volume", v).Msg("removing leftover job volume") | |||
| 430 | + | if err := d.eng.RemoveVolume(ctx, v); err != nil { | |||
| 431 | + | errs = append(errs, err) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | if err := d.net.Reset(ctx); err != nil { | |||
| 435 | + | errs = append(errs, fmt.Errorf("reset nftables table: %w", err)) | |||
| 436 | + | } | |||
| 437 | + | root := filepath.Join(d.cfg.StateDir, "containers") | |||
| 438 | + | ents, _ := os.ReadDir(root) | |||
| 439 | + | for _, e := range ents { | |||
| 440 | + | if sandbox.ValidID(e.Name()) { | |||
| 441 | + | if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil { | |||
| 442 | + | errs = append(errs, err) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | return errors.Join(errs...) | |||
| 447 | + | } | |||
A internal/sandbox/container/engine.go +291 -0
| @@ -0,0 +1,291 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package container | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/netip" | |
| 11 | + | ||
| 12 | + | cerrdefs "github.com/containerd/errdefs" | |
| 13 | + | "github.com/moby/moby/api/types/container" | |
| 14 | + | "github.com/moby/moby/api/types/mount" | |
| 15 | + | "github.com/moby/moby/api/types/network" | |
| 16 | + | "github.com/moby/moby/client" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // engine is the part of the Docker Engine API the driver uses. The real | |
| 20 | + | // implementation wraps github.com/moby/moby/client (the maintained client of | |
| 21 | + | // the Moby project; github.com/docker/docker/client is its frozen | |
| 22 | + | // predecessor); tests use a fake. | |
| 23 | + | type engine interface { | |
| 24 | + | Info(ctx context.Context) (daemonInfo, error) | |
| 25 | + | // EnsureImage returns nil when ref exists locally, pulling it otherwise. | |
| 26 | + | EnsureImage(ctx context.Context, ref string) error | |
| 27 | + | CreateNetwork(ctx context.Context, spec networkSpec) (string, error) | |
| 28 | + | RemoveNetwork(ctx context.Context, id string) error | |
| 29 | + | ListNetworks(ctx context.Context, labelKey string) ([]string, error) | |
| 30 | + | CreateVolume(ctx context.Context, name string, labels map[string]string) error | |
| 31 | + | RemoveVolume(ctx context.Context, name string) error | |
| 32 | + | ListVolumes(ctx context.Context, labelKey string) ([]string, error) | |
| 33 | + | CreateContainer(ctx context.Context, spec containerSpec) (string, error) | |
| 34 | + | StartContainer(ctx context.Context, id string) error | |
| 35 | + | // WaitExit returns a channel that yields the exit code once the container | |
| 36 | + | // stops running (or -1 and an error when the wait itself fails). | |
| 37 | + | WaitExit(ctx context.Context, id string) <-chan exitResult | |
| 38 | + | RemoveContainer(ctx context.Context, id string) error | |
| 39 | + | ListContainers(ctx context.Context, labelKey string) ([]string, error) | |
| 40 | + | LogsTail(ctx context.Context, id string, lines int) (string, error) | |
| 41 | + | Close() error | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type exitResult struct { | |
| 45 | + | Code int | |
| 46 | + | Err error | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type daemonInfo struct { | |
| 50 | + | Rootless bool | |
| 51 | + | Runtimes []string | |
| 52 | + | OSType string | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type networkSpec struct { | |
| 56 | + | Name string | |
| 57 | + | Bridge string // host bridge device name | |
| 58 | + | Subnet netip.Prefix | |
| 59 | + | Gateway netip.Addr | |
| 60 | + | Labels map[string]string | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type containerSpec struct { | |
| 64 | + | Name string | |
| 65 | + | Image string | |
| 66 | + | Entrypoint []string | |
| 67 | + | Env []string | |
| 68 | + | Labels map[string]string | |
| 69 | + | Runtime string // "" = daemon default | |
| 70 | + | Privileged bool | |
| 71 | + | Network string | |
| 72 | + | IPv4 netip.Addr | |
| 73 | + | DNS netip.Addr | |
| 74 | + | Binds []bindMount | |
| 75 | + | Volumes []volumeMount | |
| 76 | + | MemoryMiB int | |
| 77 | + | CPUs int | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | type bindMount struct { | |
| 81 | + | Source, Target string | |
| 82 | + | ReadOnly bool | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | type volumeMount struct{ Name, Target string } | |
| 86 | + | ||
| 87 | + | // dockerEngine implements engine with the moby client. | |
| 88 | + | type dockerEngine struct{ cli *client.Client } | |
| 89 | + | ||
| 90 | + | // newDockerEngine connects like the docker CLI: DOCKER_HOST, DOCKER_TLS_VERIFY, | |
| 91 | + | // DOCKER_CERT_PATH, DOCKER_API_VERSION, with API version negotiation. | |
| 92 | + | func newDockerEngine() (*dockerEngine, error) { | |
| 93 | + | cli, err := client.New(client.FromEnv) | |
| 94 | + | if err != nil { | |
| 95 | + | return nil, fmt.Errorf("docker client: %w", err) | |
| 96 | + | } | |
| 97 | + | return &dockerEngine{cli: cli}, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (e *dockerEngine) Close() error { return e.cli.Close() } | |
| 101 | + | ||
| 102 | + | func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) { | |
| 103 | + | r, err := e.cli.Info(ctx, client.InfoOptions{}) | |
| 104 | + | if err != nil { | |
| 105 | + | return daemonInfo{}, fmt.Errorf("docker info: %w", err) | |
| 106 | + | } | |
| 107 | + | di := daemonInfo{OSType: r.Info.OSType} | |
| 108 | + | for _, o := range r.Info.SecurityOptions { | |
| 109 | + | if o == "name=rootless" { | |
| 110 | + | di.Rootless = true | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | for name := range r.Info.Runtimes { | |
| 114 | + | di.Runtimes = append(di.Runtimes, name) | |
| 115 | + | } | |
| 116 | + | return di, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (e *dockerEngine) EnsureImage(ctx context.Context, ref string) error { | |
| 120 | + | if _, err := e.cli.ImageInspect(ctx, ref); err == nil { | |
| 121 | + | return nil | |
| 122 | + | } else if !cerrdefs.IsNotFound(err) { | |
| 123 | + | return fmt.Errorf("inspect image %s: %w", ref, err) | |
| 124 | + | } | |
| 125 | + | rc, err := e.cli.ImagePull(ctx, ref, client.ImagePullOptions{}) | |
| 126 | + | if err != nil { | |
| 127 | + | return fmt.Errorf("image %s is not present and cannot be pulled (build it with scripts/build-image.sh): %w", ref, err) | |
| 128 | + | } | |
| 129 | + | defer func() { _ = rc.Close() }() | |
| 130 | + | if err := rc.Wait(ctx); err != nil { | |
| 131 | + | return fmt.Errorf("pull image %s: %w", ref, err) | |
| 132 | + | } | |
| 133 | + | return nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (e *dockerEngine) CreateNetwork(ctx context.Context, s networkSpec) (string, error) { | |
| 137 | + | f := false | |
| 138 | + | r, err := e.cli.NetworkCreate(ctx, s.Name, client.NetworkCreateOptions{ | |
| 139 | + | Driver: "bridge", | |
| 140 | + | EnableIPv6: &f, | |
| 141 | + | IPAM: &network.IPAM{Config: []network.IPAMConfig{{ | |
| 142 | + | Subnet: s.Subnet, Gateway: s.Gateway, | |
| 143 | + | }}}, | |
| 144 | + | Options: map[string]string{ | |
| 145 | + | "com.docker.network.bridge.name": s.Bridge, | |
| 146 | + | // The runner's nftables table does the NAT, with its own policy. | |
| 147 | + | "com.docker.network.bridge.enable_ip_masquerade": "false", | |
| 148 | + | }, | |
| 149 | + | Labels: s.Labels, | |
| 150 | + | }) | |
| 151 | + | if err != nil { | |
| 152 | + | return "", fmt.Errorf("create network %s: %w", s.Name, err) | |
| 153 | + | } | |
| 154 | + | return r.ID, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (e *dockerEngine) RemoveNetwork(ctx context.Context, id string) error { | |
| 158 | + | _, err := e.cli.NetworkRemove(ctx, id, client.NetworkRemoveOptions{}) | |
| 159 | + | return ignoreNotFound(err) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (e *dockerEngine) ListNetworks(ctx context.Context, key string) ([]string, error) { | |
| 163 | + | r, err := e.cli.NetworkList(ctx, client.NetworkListOptions{Filters: client.Filters{}.Add("label", key)}) | |
| 164 | + | if err != nil { | |
| 165 | + | return nil, err | |
| 166 | + | } | |
| 167 | + | var ids []string | |
| 168 | + | for _, n := range r.Items { | |
| 169 | + | ids = append(ids, n.ID) | |
| 170 | + | } | |
| 171 | + | return ids, nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (e *dockerEngine) CreateVolume(ctx context.Context, name string, labels map[string]string) error { | |
| 175 | + | _, err := e.cli.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name, Labels: labels}) | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (e *dockerEngine) RemoveVolume(ctx context.Context, name string) error { | |
| 180 | + | _, err := e.cli.VolumeRemove(ctx, name, client.VolumeRemoveOptions{Force: true}) | |
| 181 | + | return ignoreNotFound(err) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (e *dockerEngine) ListVolumes(ctx context.Context, key string) ([]string, error) { | |
| 185 | + | r, err := e.cli.VolumeList(ctx, client.VolumeListOptions{Filters: client.Filters{}.Add("label", key)}) | |
| 186 | + | if err != nil { | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | var names []string | |
| 190 | + | for _, v := range r.Items { | |
| 191 | + | names = append(names, v.Name) | |
| 192 | + | } | |
| 193 | + | return names, nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (e *dockerEngine) CreateContainer(ctx context.Context, s containerSpec) (string, error) { | |
| 197 | + | hc := &container.HostConfig{ | |
| 198 | + | NetworkMode: container.NetworkMode(s.Network), | |
| 199 | + | Privileged: s.Privileged, | |
| 200 | + | Runtime: s.Runtime, | |
| 201 | + | CgroupnsMode: container.CgroupnsModePrivate, | |
| 202 | + | DNS: []netip.Addr{s.DNS}, | |
| 203 | + | Sysctls: map[string]string{"net.ipv6.conf.all.disable_ipv6": "1", "net.ipv6.conf.default.disable_ipv6": "1"}, | |
| 204 | + | } | |
| 205 | + | if s.MemoryMiB > 0 { | |
| 206 | + | hc.Memory = int64(s.MemoryMiB) << 20 | |
| 207 | + | } | |
| 208 | + | if s.CPUs > 0 { | |
| 209 | + | hc.NanoCPUs = int64(s.CPUs) * 1_000_000_000 | |
| 210 | + | } | |
| 211 | + | for _, b := range s.Binds { | |
| 212 | + | hc.Mounts = append(hc.Mounts, mount.Mount{Type: mount.TypeBind, Source: b.Source, Target: b.Target, ReadOnly: b.ReadOnly, | |
| 213 | + | BindOptions: &mount.BindOptions{CreateMountpoint: true}}) | |
| 214 | + | } | |
| 215 | + | for _, v := range s.Volumes { | |
| 216 | + | hc.Mounts = append(hc.Mounts, mount.Mount{Type: mount.TypeVolume, Source: v.Name, Target: v.Target}) | |
| 217 | + | } | |
| 218 | + | cfg := &container.Config{ | |
| 219 | + | Image: s.Image, | |
| 220 | + | Entrypoint: s.Entrypoint, | |
| 221 | + | Cmd: []string{}, | |
| 222 | + | Env: s.Env, | |
| 223 | + | Labels: s.Labels, | |
| 224 | + | Hostname: "ermite-job", | |
| 225 | + | } | |
| 226 | + | nc := &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{ | |
| 227 | + | s.Network: {IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: s.IPv4}}, | |
| 228 | + | }} | |
| 229 | + | r, err := e.cli.ContainerCreate(ctx, client.ContainerCreateOptions{Name: s.Name, Config: cfg, HostConfig: hc, NetworkingConfig: nc}) | |
| 230 | + | if err != nil { | |
| 231 | + | return "", fmt.Errorf("create container: %w", err) | |
| 232 | + | } | |
| 233 | + | return r.ID, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (e *dockerEngine) StartContainer(ctx context.Context, id string) error { | |
| 237 | + | _, err := e.cli.ContainerStart(ctx, id, client.ContainerStartOptions{}) | |
| 238 | + | return err | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (e *dockerEngine) WaitExit(ctx context.Context, id string) <-chan exitResult { | |
| 242 | + | out := make(chan exitResult, 1) | |
| 243 | + | w := e.cli.ContainerWait(ctx, id, client.ContainerWaitOptions{Condition: container.WaitConditionNextExit}) | |
| 244 | + | go func() { | |
| 245 | + | select { | |
| 246 | + | case r := <-w.Result: | |
| 247 | + | out <- exitResult{Code: int(r.StatusCode)} | |
| 248 | + | case err := <-w.Error: | |
| 249 | + | out <- exitResult{Code: -1, Err: err} | |
| 250 | + | case <-ctx.Done(): | |
| 251 | + | out <- exitResult{Code: -1, Err: ctx.Err()} | |
| 252 | + | } | |
| 253 | + | }() | |
| 254 | + | return out | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func (e *dockerEngine) RemoveContainer(ctx context.Context, id string) error { | |
| 258 | + | _, err := e.cli.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) | |
| 259 | + | return ignoreNotFound(err) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (e *dockerEngine) ListContainers(ctx context.Context, key string) ([]string, error) { | |
| 263 | + | r, err := e.cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: client.Filters{}.Add("label", key)}) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | var ids []string | |
| 268 | + | for _, c := range r.Items { | |
| 269 | + | ids = append(ids, c.ID) | |
| 270 | + | } | |
| 271 | + | return ids, nil | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (e *dockerEngine) LogsTail(ctx context.Context, id string, lines int) (string, error) { | |
| 275 | + | rc, err := e.cli.ContainerLogs(ctx, id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: fmt.Sprint(lines)}) | |
| 276 | + | if err != nil { | |
| 277 | + | return "", err | |
| 278 | + | } | |
| 279 | + | defer func() { _ = rc.Close() }() | |
| 280 | + | b, err := io.ReadAll(io.LimitReader(rc, 64<<10)) | |
| 281 | + | return string(b), err | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func ignoreNotFound(err error) error { | |
| 285 | + | if err == nil || cerrdefs.IsNotFound(err) { | |
| 286 | + | return nil | |
| 287 | + | } | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | var errRootless = errors.New("the Docker daemon is rootless: a job needs its own nested dockerd, which requires a rootful daemon (or the sysbox runtime); point DOCKER_HOST at a rootful daemon or use ERMITE_RUNNER_ISOLATION=vm") | |
| @@ -0,0 +1,291 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package container | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/netip" | |||
| 11 | + | ||||
| 12 | + | cerrdefs "github.com/containerd/errdefs" | |||
| 13 | + | "github.com/moby/moby/api/types/container" | |||
| 14 | + | "github.com/moby/moby/api/types/mount" | |||
| 15 | + | "github.com/moby/moby/api/types/network" | |||
| 16 | + | "github.com/moby/moby/client" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // engine is the part of the Docker Engine API the driver uses. The real | |||
| 20 | + | // implementation wraps github.com/moby/moby/client (the maintained client of | |||
| 21 | + | // the Moby project; github.com/docker/docker/client is its frozen | |||
| 22 | + | // predecessor); tests use a fake. | |||
| 23 | + | type engine interface { | |||
| 24 | + | Info(ctx context.Context) (daemonInfo, error) | |||
| 25 | + | // EnsureImage returns nil when ref exists locally, pulling it otherwise. | |||
| 26 | + | EnsureImage(ctx context.Context, ref string) error | |||
| 27 | + | CreateNetwork(ctx context.Context, spec networkSpec) (string, error) | |||
| 28 | + | RemoveNetwork(ctx context.Context, id string) error | |||
| 29 | + | ListNetworks(ctx context.Context, labelKey string) ([]string, error) | |||
| 30 | + | CreateVolume(ctx context.Context, name string, labels map[string]string) error | |||
| 31 | + | RemoveVolume(ctx context.Context, name string) error | |||
| 32 | + | ListVolumes(ctx context.Context, labelKey string) ([]string, error) | |||
| 33 | + | CreateContainer(ctx context.Context, spec containerSpec) (string, error) | |||
| 34 | + | StartContainer(ctx context.Context, id string) error | |||
| 35 | + | // WaitExit returns a channel that yields the exit code once the container | |||
| 36 | + | // stops running (or -1 and an error when the wait itself fails). | |||
| 37 | + | WaitExit(ctx context.Context, id string) <-chan exitResult | |||
| 38 | + | RemoveContainer(ctx context.Context, id string) error | |||
| 39 | + | ListContainers(ctx context.Context, labelKey string) ([]string, error) | |||
| 40 | + | LogsTail(ctx context.Context, id string, lines int) (string, error) | |||
| 41 | + | Close() error | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type exitResult struct { | |||
| 45 | + | Code int | |||
| 46 | + | Err error | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | type daemonInfo struct { | |||
| 50 | + | Rootless bool | |||
| 51 | + | Runtimes []string | |||
| 52 | + | OSType string | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | type networkSpec struct { | |||
| 56 | + | Name string | |||
| 57 | + | Bridge string // host bridge device name | |||
| 58 | + | Subnet netip.Prefix | |||
| 59 | + | Gateway netip.Addr | |||
| 60 | + | Labels map[string]string | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type containerSpec struct { | |||
| 64 | + | Name string | |||
| 65 | + | Image string | |||
| 66 | + | Entrypoint []string | |||
| 67 | + | Env []string | |||
| 68 | + | Labels map[string]string | |||
| 69 | + | Runtime string // "" = daemon default | |||
| 70 | + | Privileged bool | |||
| 71 | + | Network string | |||
| 72 | + | IPv4 netip.Addr | |||
| 73 | + | DNS netip.Addr | |||
| 74 | + | Binds []bindMount | |||
| 75 | + | Volumes []volumeMount | |||
| 76 | + | MemoryMiB int | |||
| 77 | + | CPUs int | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | type bindMount struct { | |||
| 81 | + | Source, Target string | |||
| 82 | + | ReadOnly bool | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | type volumeMount struct{ Name, Target string } | |||
| 86 | + | ||||
| 87 | + | // dockerEngine implements engine with the moby client. | |||
| 88 | + | type dockerEngine struct{ cli *client.Client } | |||
| 89 | + | ||||
| 90 | + | // newDockerEngine connects like the docker CLI: DOCKER_HOST, DOCKER_TLS_VERIFY, | |||
| 91 | + | // DOCKER_CERT_PATH, DOCKER_API_VERSION, with API version negotiation. | |||
| 92 | + | func newDockerEngine() (*dockerEngine, error) { | |||
| 93 | + | cli, err := client.New(client.FromEnv) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return nil, fmt.Errorf("docker client: %w", err) | |||
| 96 | + | } | |||
| 97 | + | return &dockerEngine{cli: cli}, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (e *dockerEngine) Close() error { return e.cli.Close() } | |||
| 101 | + | ||||
| 102 | + | func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) { | |||
| 103 | + | r, err := e.cli.Info(ctx, client.InfoOptions{}) | |||
| 104 | + | if err != nil { | |||
| 105 | + | return daemonInfo{}, fmt.Errorf("docker info: %w", err) | |||
| 106 | + | } | |||
| 107 | + | di := daemonInfo{OSType: r.Info.OSType} | |||
| 108 | + | for _, o := range r.Info.SecurityOptions { | |||
| 109 | + | if o == "name=rootless" { | |||
| 110 | + | di.Rootless = true | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | for name := range r.Info.Runtimes { | |||
| 114 | + | di.Runtimes = append(di.Runtimes, name) | |||
| 115 | + | } | |||
| 116 | + | return di, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (e *dockerEngine) EnsureImage(ctx context.Context, ref string) error { | |||
| 120 | + | if _, err := e.cli.ImageInspect(ctx, ref); err == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } else if !cerrdefs.IsNotFound(err) { | |||
| 123 | + | return fmt.Errorf("inspect image %s: %w", ref, err) | |||
| 124 | + | } | |||
| 125 | + | rc, err := e.cli.ImagePull(ctx, ref, client.ImagePullOptions{}) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return fmt.Errorf("image %s is not present and cannot be pulled (build it with scripts/build-image.sh): %w", ref, err) | |||
| 128 | + | } | |||
| 129 | + | defer func() { _ = rc.Close() }() | |||
| 130 | + | if err := rc.Wait(ctx); err != nil { | |||
| 131 | + | return fmt.Errorf("pull image %s: %w", ref, err) | |||
| 132 | + | } | |||
| 133 | + | return nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (e *dockerEngine) CreateNetwork(ctx context.Context, s networkSpec) (string, error) { | |||
| 137 | + | f := false | |||
| 138 | + | r, err := e.cli.NetworkCreate(ctx, s.Name, client.NetworkCreateOptions{ | |||
| 139 | + | Driver: "bridge", | |||
| 140 | + | EnableIPv6: &f, | |||
| 141 | + | IPAM: &network.IPAM{Config: []network.IPAMConfig{{ | |||
| 142 | + | Subnet: s.Subnet, Gateway: s.Gateway, | |||
| 143 | + | }}}, | |||
| 144 | + | Options: map[string]string{ | |||
| 145 | + | "com.docker.network.bridge.name": s.Bridge, | |||
| 146 | + | // The runner's nftables table does the NAT, with its own policy. | |||
| 147 | + | "com.docker.network.bridge.enable_ip_masquerade": "false", | |||
| 148 | + | }, | |||
| 149 | + | Labels: s.Labels, | |||
| 150 | + | }) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return "", fmt.Errorf("create network %s: %w", s.Name, err) | |||
| 153 | + | } | |||
| 154 | + | return r.ID, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (e *dockerEngine) RemoveNetwork(ctx context.Context, id string) error { | |||
| 158 | + | _, err := e.cli.NetworkRemove(ctx, id, client.NetworkRemoveOptions{}) | |||
| 159 | + | return ignoreNotFound(err) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (e *dockerEngine) ListNetworks(ctx context.Context, key string) ([]string, error) { | |||
| 163 | + | r, err := e.cli.NetworkList(ctx, client.NetworkListOptions{Filters: client.Filters{}.Add("label", key)}) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return nil, err | |||
| 166 | + | } | |||
| 167 | + | var ids []string | |||
| 168 | + | for _, n := range r.Items { | |||
| 169 | + | ids = append(ids, n.ID) | |||
| 170 | + | } | |||
| 171 | + | return ids, nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (e *dockerEngine) CreateVolume(ctx context.Context, name string, labels map[string]string) error { | |||
| 175 | + | _, err := e.cli.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name, Labels: labels}) | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (e *dockerEngine) RemoveVolume(ctx context.Context, name string) error { | |||
| 180 | + | _, err := e.cli.VolumeRemove(ctx, name, client.VolumeRemoveOptions{Force: true}) | |||
| 181 | + | return ignoreNotFound(err) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (e *dockerEngine) ListVolumes(ctx context.Context, key string) ([]string, error) { | |||
| 185 | + | r, err := e.cli.VolumeList(ctx, client.VolumeListOptions{Filters: client.Filters{}.Add("label", key)}) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return nil, err | |||
| 188 | + | } | |||
| 189 | + | var names []string | |||
| 190 | + | for _, v := range r.Items { | |||
| 191 | + | names = append(names, v.Name) | |||
| 192 | + | } | |||
| 193 | + | return names, nil | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (e *dockerEngine) CreateContainer(ctx context.Context, s containerSpec) (string, error) { | |||
| 197 | + | hc := &container.HostConfig{ | |||
| 198 | + | NetworkMode: container.NetworkMode(s.Network), | |||
| 199 | + | Privileged: s.Privileged, | |||
| 200 | + | Runtime: s.Runtime, | |||
| 201 | + | CgroupnsMode: container.CgroupnsModePrivate, | |||
| 202 | + | DNS: []netip.Addr{s.DNS}, | |||
| 203 | + | Sysctls: map[string]string{"net.ipv6.conf.all.disable_ipv6": "1", "net.ipv6.conf.default.disable_ipv6": "1"}, | |||
| 204 | + | } | |||
| 205 | + | if s.MemoryMiB > 0 { | |||
| 206 | + | hc.Memory = int64(s.MemoryMiB) << 20 | |||
| 207 | + | } | |||
| 208 | + | if s.CPUs > 0 { | |||
| 209 | + | hc.NanoCPUs = int64(s.CPUs) * 1_000_000_000 | |||
| 210 | + | } | |||
| 211 | + | for _, b := range s.Binds { | |||
| 212 | + | hc.Mounts = append(hc.Mounts, mount.Mount{Type: mount.TypeBind, Source: b.Source, Target: b.Target, ReadOnly: b.ReadOnly, | |||
| 213 | + | BindOptions: &mount.BindOptions{CreateMountpoint: true}}) | |||
| 214 | + | } | |||
| 215 | + | for _, v := range s.Volumes { | |||
| 216 | + | hc.Mounts = append(hc.Mounts, mount.Mount{Type: mount.TypeVolume, Source: v.Name, Target: v.Target}) | |||
| 217 | + | } | |||
| 218 | + | cfg := &container.Config{ | |||
| 219 | + | Image: s.Image, | |||
| 220 | + | Entrypoint: s.Entrypoint, | |||
| 221 | + | Cmd: []string{}, | |||
| 222 | + | Env: s.Env, | |||
| 223 | + | Labels: s.Labels, | |||
| 224 | + | Hostname: "ermite-job", | |||
| 225 | + | } | |||
| 226 | + | nc := &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{ | |||
| 227 | + | s.Network: {IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: s.IPv4}}, | |||
| 228 | + | }} | |||
| 229 | + | r, err := e.cli.ContainerCreate(ctx, client.ContainerCreateOptions{Name: s.Name, Config: cfg, HostConfig: hc, NetworkingConfig: nc}) | |||
| 230 | + | if err != nil { | |||
| 231 | + | return "", fmt.Errorf("create container: %w", err) | |||
| 232 | + | } | |||
| 233 | + | return r.ID, nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (e *dockerEngine) StartContainer(ctx context.Context, id string) error { | |||
| 237 | + | _, err := e.cli.ContainerStart(ctx, id, client.ContainerStartOptions{}) | |||
| 238 | + | return err | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (e *dockerEngine) WaitExit(ctx context.Context, id string) <-chan exitResult { | |||
| 242 | + | out := make(chan exitResult, 1) | |||
| 243 | + | w := e.cli.ContainerWait(ctx, id, client.ContainerWaitOptions{Condition: container.WaitConditionNextExit}) | |||
| 244 | + | go func() { | |||
| 245 | + | select { | |||
| 246 | + | case r := <-w.Result: | |||
| 247 | + | out <- exitResult{Code: int(r.StatusCode)} | |||
| 248 | + | case err := <-w.Error: | |||
| 249 | + | out <- exitResult{Code: -1, Err: err} | |||
| 250 | + | case <-ctx.Done(): | |||
| 251 | + | out <- exitResult{Code: -1, Err: ctx.Err()} | |||
| 252 | + | } | |||
| 253 | + | }() | |||
| 254 | + | return out | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func (e *dockerEngine) RemoveContainer(ctx context.Context, id string) error { | |||
| 258 | + | _, err := e.cli.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) | |||
| 259 | + | return ignoreNotFound(err) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (e *dockerEngine) ListContainers(ctx context.Context, key string) ([]string, error) { | |||
| 263 | + | r, err := e.cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: client.Filters{}.Add("label", key)}) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return nil, err | |||
| 266 | + | } | |||
| 267 | + | var ids []string | |||
| 268 | + | for _, c := range r.Items { | |||
| 269 | + | ids = append(ids, c.ID) | |||
| 270 | + | } | |||
| 271 | + | return ids, nil | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func (e *dockerEngine) LogsTail(ctx context.Context, id string, lines int) (string, error) { | |||
| 275 | + | rc, err := e.cli.ContainerLogs(ctx, id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: fmt.Sprint(lines)}) | |||
| 276 | + | if err != nil { | |||
| 277 | + | return "", err | |||
| 278 | + | } | |||
| 279 | + | defer func() { _ = rc.Close() }() | |||
| 280 | + | b, err := io.ReadAll(io.LimitReader(rc, 64<<10)) | |||
| 281 | + | return string(b), err | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func ignoreNotFound(err error) error { | |||
| 285 | + | if err == nil || cerrdefs.IsNotFound(err) { | |||
| 286 | + | return nil | |||
| 287 | + | } | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | var errRootless = errors.New("the Docker daemon is rootless: a job needs its own nested dockerd, which requires a rootful daemon (or the sysbox runtime); point DOCKER_HOST at a rootful daemon or use ERMITE_RUNNER_ISOLATION=vm") | |||
A internal/sandbox/drivers/drivers.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package drivers wires the sandbox drivers into sandbox.Select. | |
| 4 | + | package drivers | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-runner/internal/sandbox" | |
| 10 | + | "bnema.dev/ermite-runner/internal/sandbox/container" | |
| 11 | + | "bnema.dev/ermite-runner/internal/sandbox/vm" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Factories returns the real vm and container driver factories. | |
| 15 | + | func Factories() sandbox.Factories { | |
| 16 | + | return sandbox.Factories{ | |
| 17 | + | VM: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) { | |
| 18 | + | return vm.New(ctx, cfg, vm.Options{}) | |
| 19 | + | }, | |
| 20 | + | Container: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) { | |
| 21 | + | return container.New(ctx, cfg, container.Options{}) | |
| 22 | + | }, | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package drivers wires the sandbox drivers into sandbox.Select. | |||
| 4 | + | package drivers | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "context" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite-runner/internal/sandbox" | |||
| 10 | + | "bnema.dev/ermite-runner/internal/sandbox/container" | |||
| 11 | + | "bnema.dev/ermite-runner/internal/sandbox/vm" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Factories returns the real vm and container driver factories. | |||
| 15 | + | func Factories() sandbox.Factories { | |||
| 16 | + | return sandbox.Factories{ | |||
| 17 | + | VM: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) { | |||
| 18 | + | return vm.New(ctx, cfg, vm.Options{}) | |||
| 19 | + | }, | |||
| 20 | + | Container: func(ctx context.Context, cfg sandbox.Config) (sandbox.Driver, error) { | |||
| 21 | + | return container.New(ctx, cfg, container.Options{}) | |||
| 22 | + | }, | |||
| 23 | + | } | |||
| 24 | + | } | |||