openlattice-docker 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,762 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { DockerProvider } from "../src/docker-provider";
3
+
4
+ // ── Mock dockerode ──────────────────────────────────────────────────
5
+ // vi.mock is hoisted, so all referenced variables must also be hoisted
6
+ // via vi.hoisted() to avoid temporal dead zone issues.
7
+
8
+ const mocks = vi.hoisted(() => {
9
+ const mockExecInspect = vi.fn().mockResolvedValue({
10
+ Running: false,
11
+ ExitCode: 0,
12
+ });
13
+
14
+ const mockExecStart = vi.fn().mockImplementation(() => {
15
+ const { PassThrough } = require("stream");
16
+ const stream = new PassThrough();
17
+ setTimeout(() => stream.end(), 10);
18
+ return Promise.resolve(stream);
19
+ });
20
+
21
+ const mockExec = vi.fn().mockResolvedValue({
22
+ start: mockExecStart,
23
+ inspect: mockExecInspect,
24
+ });
25
+
26
+ const mockRemove = vi.fn().mockResolvedValue(undefined);
27
+ const mockStart = vi.fn().mockResolvedValue(undefined);
28
+ const mockStop = vi.fn().mockResolvedValue(undefined);
29
+ const mockPause = vi.fn().mockResolvedValue(undefined);
30
+ const mockUnpause = vi.fn().mockResolvedValue(undefined);
31
+ const mockCommit = vi
32
+ .fn()
33
+ .mockResolvedValue({ Id: "sha256:snapshot_image_123" });
34
+ const mockStats = vi.fn().mockResolvedValue({
35
+ cpu_stats: {
36
+ cpu_usage: { total_usage: 100000, percpu_usage: [100000] },
37
+ system_cpu_usage: 1000000,
38
+ online_cpus: 1,
39
+ },
40
+ precpu_stats: {
41
+ cpu_usage: { total_usage: 50000 },
42
+ system_cpu_usage: 500000,
43
+ },
44
+ memory_stats: { usage: 256 * 1024 * 1024 },
45
+ });
46
+
47
+ const mockInspect = vi.fn().mockResolvedValue({
48
+ Id: "abc123def456",
49
+ State: {
50
+ Status: "running",
51
+ StartedAt: "2026-01-15T10:00:00.000Z",
52
+ },
53
+ NetworkSettings: {
54
+ Ports: {
55
+ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }],
56
+ },
57
+ },
58
+ });
59
+
60
+ const mockLogs = vi.fn().mockImplementation(() => {
61
+ const { PassThrough } = require("stream");
62
+ return Promise.resolve(new PassThrough());
63
+ });
64
+
65
+ const mockContainer = {
66
+ id: "abc123def456",
67
+ start: mockStart,
68
+ stop: mockStop,
69
+ pause: mockPause,
70
+ unpause: mockUnpause,
71
+ remove: mockRemove,
72
+ inspect: mockInspect,
73
+ exec: mockExec,
74
+ commit: mockCommit,
75
+ stats: mockStats,
76
+ logs: mockLogs,
77
+ };
78
+
79
+ const mockCreateContainer = vi.fn().mockResolvedValue(mockContainer);
80
+
81
+ const mockDocker = {
82
+ createContainer: mockCreateContainer,
83
+ getContainer: vi.fn().mockReturnValue(mockContainer),
84
+ ping: vi.fn().mockResolvedValue("OK"),
85
+ info: vi.fn().mockResolvedValue({ Runtimes: {} }),
86
+ modem: { demuxStream: vi.fn() },
87
+ };
88
+
89
+ return {
90
+ mockExecInspect,
91
+ mockExecStart,
92
+ mockExec,
93
+ mockRemove,
94
+ mockStart,
95
+ mockStop,
96
+ mockPause,
97
+ mockUnpause,
98
+ mockCommit,
99
+ mockStats,
100
+ mockInspect,
101
+ mockLogs,
102
+ mockContainer,
103
+ mockCreateContainer,
104
+ mockDocker,
105
+ };
106
+ });
107
+
108
+ vi.mock("dockerode", () => ({
109
+ default: vi.fn(function () {
110
+ return mocks.mockDocker;
111
+ }),
112
+ }));
113
+
114
+ // ── Tests ───────────────────────────────────────────────────────────
115
+
116
+ describe("DockerProvider", () => {
117
+ let provider: DockerProvider;
118
+
119
+ beforeEach(() => {
120
+ vi.clearAllMocks();
121
+ // Restore default implementations after clearAllMocks
122
+ mocks.mockExecInspect.mockResolvedValue({ Running: false, ExitCode: 0 });
123
+ mocks.mockExecStart.mockImplementation(() => {
124
+ const { PassThrough } = require("stream");
125
+ const stream = new PassThrough();
126
+ setTimeout(() => stream.end(), 10);
127
+ return Promise.resolve(stream);
128
+ });
129
+ mocks.mockExec.mockResolvedValue({
130
+ start: mocks.mockExecStart,
131
+ inspect: mocks.mockExecInspect,
132
+ });
133
+ mocks.mockRemove.mockResolvedValue(undefined);
134
+ mocks.mockStart.mockResolvedValue(undefined);
135
+ mocks.mockStop.mockResolvedValue(undefined);
136
+ mocks.mockPause.mockResolvedValue(undefined);
137
+ mocks.mockUnpause.mockResolvedValue(undefined);
138
+ mocks.mockCommit.mockResolvedValue({ Id: "sha256:snapshot_image_123" });
139
+ mocks.mockStats.mockResolvedValue({
140
+ cpu_stats: {
141
+ cpu_usage: { total_usage: 100000, percpu_usage: [100000] },
142
+ system_cpu_usage: 1000000,
143
+ online_cpus: 1,
144
+ },
145
+ precpu_stats: {
146
+ cpu_usage: { total_usage: 50000 },
147
+ system_cpu_usage: 500000,
148
+ },
149
+ memory_stats: { usage: 256 * 1024 * 1024 },
150
+ });
151
+ mocks.mockInspect.mockResolvedValue({
152
+ Id: "abc123def456",
153
+ State: {
154
+ Status: "running",
155
+ StartedAt: "2026-01-15T10:00:00.000Z",
156
+ },
157
+ NetworkSettings: {
158
+ Ports: {
159
+ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }],
160
+ },
161
+ },
162
+ });
163
+ mocks.mockLogs.mockImplementation(() => {
164
+ const { PassThrough } = require("stream");
165
+ return Promise.resolve(new PassThrough());
166
+ });
167
+ mocks.mockCreateContainer.mockResolvedValue(mocks.mockContainer);
168
+ mocks.mockDocker.getContainer.mockReturnValue(mocks.mockContainer);
169
+ mocks.mockDocker.ping.mockResolvedValue("OK");
170
+ mocks.mockDocker.info.mockResolvedValue({ Runtimes: {} });
171
+
172
+ provider = new DockerProvider({});
173
+ });
174
+
175
+ describe("constructor", () => {
176
+ it("sets name to 'docker'", () => {
177
+ expect(provider.name).toBe("docker");
178
+ });
179
+
180
+ it("declares expected capabilities", () => {
181
+ expect(provider.capabilities.restart).toBe(true);
182
+ expect(provider.capabilities.pause).toBe(true);
183
+ expect(provider.capabilities.snapshot).toBe(true);
184
+ expect(provider.capabilities.logs).toBe(true);
185
+ expect(provider.capabilities.tailscale).toBe(true);
186
+ expect(provider.capabilities.persistentStorage).toBe(true);
187
+ expect(provider.capabilities.architectures).toContain("x86_64");
188
+ expect(provider.capabilities.architectures).toContain("arm64");
189
+ });
190
+
191
+ it("defaults gpu to false without explicit config", () => {
192
+ expect(provider.capabilities.gpu).toBe(false);
193
+ });
194
+
195
+ it("respects gpuAvailable config", () => {
196
+ const gpuProvider = new DockerProvider({ gpuAvailable: true });
197
+ expect(gpuProvider.capabilities.gpu).toBe(true);
198
+ });
199
+ });
200
+
201
+ describe("provision", () => {
202
+ it("creates and starts a container", async () => {
203
+ const node = await provider.provision({
204
+ runtime: { image: "python:3.11" },
205
+ });
206
+
207
+ expect(mocks.mockCreateContainer).toHaveBeenCalledOnce();
208
+ expect(mocks.mockStart).toHaveBeenCalledOnce();
209
+ expect(mocks.mockInspect).toHaveBeenCalledOnce();
210
+ expect(node.externalId).toBe("abc123def456");
211
+ });
212
+
213
+ it("maps runtime image to Docker Image option", async () => {
214
+ await provider.provision({
215
+ runtime: { image: "node:20-alpine" },
216
+ });
217
+
218
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
219
+ expect(opts.Image).toBe("node:20-alpine");
220
+ });
221
+
222
+ it("maps runtime command to Cmd", async () => {
223
+ await provider.provision({
224
+ runtime: { image: "alpine", command: ["sh", "-c", "echo hello"] },
225
+ });
226
+
227
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
228
+ expect(opts.Cmd).toEqual(["sh", "-c", "echo hello"]);
229
+ });
230
+
231
+ it("maps runtime env to Env array", async () => {
232
+ await provider.provision({
233
+ runtime: { image: "alpine", env: { FOO: "bar", BAZ: "qux" } },
234
+ });
235
+
236
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
237
+ expect(opts.Env).toEqual(["FOO=bar", "BAZ=qux"]);
238
+ });
239
+
240
+ it("maps CPU cores to NanoCpus", async () => {
241
+ await provider.provision({
242
+ runtime: { image: "alpine" },
243
+ cpu: { cores: 4 },
244
+ });
245
+
246
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
247
+ expect(opts.HostConfig.NanoCpus).toBe(4e9);
248
+ });
249
+
250
+ it("maps memory GiB to bytes", async () => {
251
+ await provider.provision({
252
+ runtime: { image: "alpine" },
253
+ memory: { sizeGiB: 2 },
254
+ });
255
+
256
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
257
+ expect(opts.HostConfig.Memory).toBe(2 * 1024 * 1024 * 1024);
258
+ });
259
+
260
+ it("maps GPU spec to DeviceRequests", async () => {
261
+ const gpuProvider = new DockerProvider({ gpuAvailable: true });
262
+ await gpuProvider.provision({
263
+ runtime: { image: "nvidia/cuda" },
264
+ gpu: { count: 2 },
265
+ });
266
+
267
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
268
+ expect(opts.HostConfig.DeviceRequests).toEqual([
269
+ {
270
+ Driver: "nvidia",
271
+ Count: 2,
272
+ Capabilities: [["gpu"]],
273
+ },
274
+ ]);
275
+ });
276
+
277
+ it("maps port bindings", async () => {
278
+ await provider.provision({
279
+ runtime: { image: "alpine" },
280
+ network: { ports: [{ port: 3000, protocol: "tcp" }] },
281
+ });
282
+
283
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
284
+ expect(opts.HostConfig.PortBindings).toEqual({
285
+ "3000/tcp": [{ HostPort: "3000" }],
286
+ });
287
+ expect(opts.ExposedPorts).toEqual({ "3000/tcp": {} });
288
+ });
289
+
290
+ it("applies openlattice.managed and node-id labels", async () => {
291
+ await provider.provision({
292
+ runtime: { image: "alpine" },
293
+ });
294
+
295
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
296
+ expect(opts.Labels["openlattice.managed"]).toBe("true");
297
+ expect(opts.Labels["openlattice.node-id"]).toBeDefined();
298
+ });
299
+
300
+ it("applies spec labels and default labels", async () => {
301
+ const labelProvider = new DockerProvider({
302
+ defaultLabels: { env: "test" },
303
+ });
304
+ await labelProvider.provision({
305
+ runtime: { image: "alpine" },
306
+ labels: { team: "ml" },
307
+ });
308
+
309
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
310
+ expect(opts.Labels.team).toBe("ml");
311
+ expect(opts.Labels.env).toBe("test");
312
+ });
313
+
314
+ it("uses spec.name directly as container name", async () => {
315
+ await provider.provision({
316
+ name: "my-service",
317
+ runtime: { image: "alpine" },
318
+ });
319
+
320
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
321
+ expect(opts.name).toBe("my-service");
322
+ });
323
+
324
+ it("sets Hostname from spec.name", async () => {
325
+ await provider.provision({
326
+ name: "my-service",
327
+ runtime: { image: "alpine" },
328
+ });
329
+
330
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
331
+ expect(opts.Hostname).toBe("my-service");
332
+ });
333
+
334
+ it("extracts endpoints from port bindings", async () => {
335
+ const node = await provider.provision({
336
+ runtime: { image: "alpine" },
337
+ });
338
+
339
+ expect(node.endpoints).toHaveLength(1);
340
+ expect(node.endpoints[0].type).toBe("tcp");
341
+ expect(node.endpoints[0].host).toBe("localhost");
342
+ expect(node.endpoints[0].port).toBe(8080);
343
+ });
344
+
345
+ it("includes network-internal endpoints on named networks", async () => {
346
+ const netProvider = new DockerProvider({ network: "swarmhub" });
347
+
348
+ mocks.mockInspect.mockResolvedValueOnce({
349
+ Id: "abc123def456",
350
+ Name: "/my-service",
351
+ State: {
352
+ Status: "running",
353
+ StartedAt: "2026-01-15T10:00:00.000Z",
354
+ },
355
+ NetworkSettings: {
356
+ Ports: {
357
+ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }],
358
+ },
359
+ Networks: {
360
+ swarmhub: {
361
+ IPAddress: "172.20.0.5",
362
+ IPPrefixLen: 16,
363
+ },
364
+ },
365
+ },
366
+ });
367
+
368
+ const node = await netProvider.provision({
369
+ name: "my-service",
370
+ runtime: { image: "alpine" },
371
+ network: { ports: [{ port: 8080 }] },
372
+ });
373
+
374
+ const hostEndpoint = node.endpoints.find((e) => e.type === "tcp");
375
+ expect(hostEndpoint).toBeDefined();
376
+ expect(hostEndpoint!.host).toBe("localhost");
377
+ expect(hostEndpoint!.port).toBe(8080);
378
+
379
+ const internalEndpoint = node.endpoints.find(
380
+ (e) => e.type === "docker-internal"
381
+ );
382
+ expect(internalEndpoint).toBeDefined();
383
+ expect(internalEndpoint!.host).toBe("my-service");
384
+ expect(internalEndpoint!.port).toBe(8080);
385
+ expect(internalEndpoint!.url).toBe("http://my-service:8080");
386
+ });
387
+
388
+ it("does not add internal endpoints on bridge network", async () => {
389
+ const bridgeProvider = new DockerProvider({ network: "bridge" });
390
+
391
+ const node = await bridgeProvider.provision({
392
+ runtime: { image: "alpine" },
393
+ });
394
+
395
+ const internal = node.endpoints.filter(
396
+ (e) => e.type === "docker-internal"
397
+ );
398
+ expect(internal).toHaveLength(0);
399
+ });
400
+
401
+ it("defaults to sleep command when no command specified", async () => {
402
+ await provider.provision({
403
+ runtime: { image: "alpine" },
404
+ });
405
+
406
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
407
+ expect(opts.Cmd).toEqual(["sh", "-c", "sleep infinity"]);
408
+ });
409
+
410
+ it("adds CAP_NET_ADMIN and /dev/net/tun when tailscale is true", async () => {
411
+ await provider.provision({
412
+ runtime: { image: "tailscale/alpine" },
413
+ network: { tailscale: true },
414
+ });
415
+
416
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
417
+ expect(opts.HostConfig.CapAdd).toEqual(["NET_ADMIN"]);
418
+ expect(opts.HostConfig.Devices).toEqual([
419
+ {
420
+ PathOnHost: "/dev/net/tun",
421
+ PathInContainer: "/dev/net/tun",
422
+ CgroupPermissions: "rwm",
423
+ },
424
+ ]);
425
+ });
426
+
427
+ it("does not add tailscale caps when tailscale is false/undefined", async () => {
428
+ await provider.provision({
429
+ runtime: { image: "alpine" },
430
+ network: { ports: [{ port: 8080 }] },
431
+ });
432
+
433
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
434
+ expect(opts.HostConfig.CapAdd).toBeUndefined();
435
+ expect(opts.HostConfig.Devices).toBeUndefined();
436
+ });
437
+
438
+ it("combines tailscale with port bindings", async () => {
439
+ await provider.provision({
440
+ runtime: { image: "tailscale/alpine" },
441
+ network: { tailscale: true, ports: [{ port: 3000 }] },
442
+ });
443
+
444
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
445
+ expect(opts.HostConfig.CapAdd).toEqual(["NET_ADMIN"]);
446
+ expect(opts.HostConfig.Devices).toHaveLength(1);
447
+ expect(opts.HostConfig.PortBindings).toEqual({
448
+ "3000/tcp": [{ HostPort: "3000" }],
449
+ });
450
+ });
451
+
452
+ it("adds CAP_NET_ADMIN when tailscaleAuthKey is set without tailscale: true", async () => {
453
+ await provider.provision({
454
+ runtime: { image: "tailscale/alpine" },
455
+ network: { tailscaleAuthKey: "tskey-auth-abc123" },
456
+ });
457
+
458
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
459
+ expect(opts.HostConfig.CapAdd).toEqual(["NET_ADMIN"]);
460
+ expect(opts.HostConfig.Devices).toHaveLength(1);
461
+ });
462
+
463
+ it("calls tailscale up via exec when tailscaleAuthKey is set", async () => {
464
+ await provider.provision({
465
+ runtime: { image: "tailscale/alpine" },
466
+ network: { tailscaleAuthKey: "tskey-auth-abc123" },
467
+ });
468
+
469
+ // Should have exec'd at least twice during provision:
470
+ // 1. tailscaled daemon check
471
+ // 2. tailscale up --authkey
472
+ const execCalls = mocks.mockExec.mock.calls;
473
+ const cmds = execCalls.map((c: any) => c[0]?.Cmd ?? []);
474
+ const hasAuthKey = cmds.some(
475
+ (cmd: string[]) =>
476
+ cmd.includes("tailscale") && cmd.includes("tskey-auth-abc123")
477
+ );
478
+ expect(hasAuthKey).toBe(true);
479
+ });
480
+ });
481
+
482
+ describe("exec", () => {
483
+ it("creates and starts an exec instance", async () => {
484
+ const result = await provider.exec("abc123", ["echo", "hello"]);
485
+
486
+ expect(mocks.mockExec).toHaveBeenCalledOnce();
487
+ expect(mocks.mockExecStart).toHaveBeenCalledOnce();
488
+ expect(result.exitCode).toBe(0);
489
+ });
490
+
491
+ it("passes command to exec create", async () => {
492
+ await provider.exec("abc123", ["ls", "-la"]);
493
+
494
+ const execOpts = mocks.mockExec.mock.calls[0][0];
495
+ expect(execOpts.Cmd).toEqual(["ls", "-la"]);
496
+ });
497
+
498
+ it("passes cwd and env to exec create", async () => {
499
+ await provider.exec("abc123", ["npm", "test"], {
500
+ cwd: "/app",
501
+ env: { NODE_ENV: "test" },
502
+ });
503
+
504
+ const execOpts = mocks.mockExec.mock.calls[0][0];
505
+ expect(execOpts.WorkingDir).toBe("/app");
506
+ expect(execOpts.Env).toEqual(["NODE_ENV=test"]);
507
+ });
508
+
509
+ it("polls exec.inspect for completion", async () => {
510
+ // Simulate exec running then completing
511
+ mocks.mockExecInspect
512
+ .mockResolvedValueOnce({ Running: true, ExitCode: null })
513
+ .mockResolvedValueOnce({ Running: false, ExitCode: 0 });
514
+
515
+ const result = await provider.exec("abc123", ["echo", "hi"]);
516
+ expect(result.exitCode).toBe(0);
517
+ expect(mocks.mockExecInspect).toHaveBeenCalledTimes(2);
518
+ });
519
+
520
+ it("returns correct exit code from exec", async () => {
521
+ mocks.mockExecInspect.mockResolvedValueOnce({
522
+ Running: false,
523
+ ExitCode: 42,
524
+ });
525
+
526
+ const result = await provider.exec("abc123", ["false"]);
527
+ expect(result.exitCode).toBe(42);
528
+ });
529
+ });
530
+
531
+ describe("inspect", () => {
532
+ it("maps running Docker state to running", async () => {
533
+ mocks.mockInspect.mockResolvedValueOnce({
534
+ State: { Status: "running", StartedAt: "2026-01-01T00:00:00Z" },
535
+ NetworkSettings: { Ports: {} },
536
+ });
537
+
538
+ const status = await provider.inspect("abc123");
539
+ expect(status.status).toBe("running");
540
+ });
541
+
542
+ it("maps paused Docker state to paused", async () => {
543
+ mocks.mockInspect.mockResolvedValueOnce({
544
+ State: { Status: "paused", StartedAt: "2026-01-01T00:00:00Z" },
545
+ NetworkSettings: { Ports: {} },
546
+ });
547
+
548
+ const status = await provider.inspect("abc123");
549
+ expect(status.status).toBe("paused");
550
+ });
551
+
552
+ it("maps exited Docker state to stopped", async () => {
553
+ mocks.mockInspect.mockResolvedValueOnce({
554
+ State: { Status: "exited", StartedAt: "2026-01-01T00:00:00Z" },
555
+ NetworkSettings: { Ports: {} },
556
+ });
557
+
558
+ const status = await provider.inspect("abc123");
559
+ expect(status.status).toBe("stopped");
560
+ });
561
+
562
+ it("maps dead Docker state to stopped", async () => {
563
+ mocks.mockInspect.mockResolvedValueOnce({
564
+ State: { Status: "dead" },
565
+ NetworkSettings: { Ports: {} },
566
+ });
567
+
568
+ const status = await provider.inspect("abc123");
569
+ expect(status.status).toBe("stopped");
570
+ });
571
+
572
+ it("returns terminated for 404 (container not found)", async () => {
573
+ mocks.mockInspect.mockRejectedValueOnce(
574
+ Object.assign(new Error("not found"), { statusCode: 404 })
575
+ );
576
+
577
+ const status = await provider.inspect("gone");
578
+ expect(status.status).toBe("terminated");
579
+ });
580
+
581
+ it("includes resource usage from stats", async () => {
582
+ const status = await provider.inspect("abc123");
583
+ expect(status.resources).toBeDefined();
584
+ expect(status.resources!.cpuPercent).toBeTypeOf("number");
585
+ expect(status.resources!.memoryUsedMiB).toBeTypeOf("number");
586
+ });
587
+
588
+ it("parses startedAt date", async () => {
589
+ const status = await provider.inspect("abc123");
590
+ expect(status.startedAt).toBeInstanceOf(Date);
591
+ });
592
+ });
593
+
594
+ describe("destroy", () => {
595
+ it("force-removes the container with volumes", async () => {
596
+ await provider.destroy("abc123");
597
+ expect(mocks.mockRemove).toHaveBeenCalledWith(
598
+ expect.objectContaining({ force: true, v: true })
599
+ );
600
+ });
601
+
602
+ it("is idempotent for 404 (already removed)", async () => {
603
+ mocks.mockRemove.mockRejectedValueOnce(
604
+ Object.assign(new Error("not found"), { statusCode: 404 })
605
+ );
606
+
607
+ await expect(provider.destroy("abc123")).resolves.toBeUndefined();
608
+ });
609
+
610
+ it("throws on non-404 errors", async () => {
611
+ mocks.mockRemove.mockRejectedValueOnce(new Error("permission denied"));
612
+
613
+ await expect(provider.destroy("abc123")).rejects.toThrow(
614
+ "[docker] destroy failed"
615
+ );
616
+ });
617
+ });
618
+
619
+ describe("stop / start", () => {
620
+ it("stops the container with 10s grace period", async () => {
621
+ await provider.stop("abc123");
622
+ expect(mocks.mockStop).toHaveBeenCalledWith({ t: 10 });
623
+ });
624
+
625
+ it("stop is idempotent for 304 (already stopped)", async () => {
626
+ mocks.mockStop.mockRejectedValueOnce(
627
+ Object.assign(new Error("not modified"), { statusCode: 304 })
628
+ );
629
+
630
+ await expect(provider.stop("abc123")).resolves.toBeUndefined();
631
+ });
632
+
633
+ it("starts the container", async () => {
634
+ await provider.start("abc123");
635
+ expect(mocks.mockStart).toHaveBeenCalledOnce();
636
+ });
637
+
638
+ it("start is idempotent for 304 (already running)", async () => {
639
+ mocks.mockStart.mockRejectedValueOnce(
640
+ Object.assign(new Error("not modified"), { statusCode: 304 })
641
+ );
642
+
643
+ await expect(provider.start("abc123")).resolves.toBeUndefined();
644
+ });
645
+ });
646
+
647
+ describe("pause / resume", () => {
648
+ it("pauses the container", async () => {
649
+ await provider.pause("abc123");
650
+ expect(mocks.mockPause).toHaveBeenCalledOnce();
651
+ });
652
+
653
+ it("resumes (unpauses) the container", async () => {
654
+ await provider.resume("abc123");
655
+ expect(mocks.mockUnpause).toHaveBeenCalledOnce();
656
+ });
657
+ });
658
+
659
+ describe("snapshot / restore", () => {
660
+ it("commits the container as an image", async () => {
661
+ const ref = await provider.snapshot("abc123");
662
+
663
+ expect(mocks.mockCommit).toHaveBeenCalledWith(
664
+ expect.objectContaining({ repo: "openlattice-snapshot" })
665
+ );
666
+ expect(ref.provider).toBe("docker");
667
+ expect(ref.externalId).toBe("sha256:snapshot_image_123");
668
+ expect(ref.type).toBe("filesystem");
669
+ expect(ref.createdAt).toBeInstanceOf(Date);
670
+ });
671
+
672
+ it("restores by provisioning from snapshot image", async () => {
673
+ const ref = {
674
+ provider: "docker",
675
+ externalId: "sha256:snapshot_image_123",
676
+ createdAt: new Date(),
677
+ type: "filesystem" as const,
678
+ };
679
+
680
+ const node = await provider.restore(ref);
681
+
682
+ const opts = mocks.mockCreateContainer.mock.calls[0][0];
683
+ expect(opts.Image).toBe("sha256:snapshot_image_123");
684
+ expect(node.externalId).toBeTruthy();
685
+ });
686
+ });
687
+
688
+ describe("healthCheck", () => {
689
+ it("returns healthy when Docker daemon is reachable", async () => {
690
+ const health = await provider.healthCheck();
691
+
692
+ expect(health.healthy).toBe(true);
693
+ expect(health.latencyMs).toBeTypeOf("number");
694
+ expect(mocks.mockDocker.ping).toHaveBeenCalledOnce();
695
+ });
696
+
697
+ it("returns unhealthy when Docker daemon is unreachable", async () => {
698
+ mocks.mockDocker.ping.mockRejectedValueOnce(
699
+ new Error("connection refused")
700
+ );
701
+
702
+ const health = await provider.healthCheck();
703
+
704
+ expect(health.healthy).toBe(false);
705
+ expect(health.message).toContain("connection refused");
706
+ });
707
+ });
708
+
709
+ describe("getExtension", () => {
710
+ it("returns network extension", () => {
711
+ const ext = provider.getExtension("abc123", "network");
712
+ expect(ext).toBeDefined();
713
+ expect(ext).toHaveProperty("getUrl");
714
+ });
715
+
716
+ it("returns files extension", () => {
717
+ const ext = provider.getExtension("abc123", "files");
718
+ expect(ext).toBeDefined();
719
+ expect(ext).toHaveProperty("read");
720
+ expect(ext).toHaveProperty("write");
721
+ expect(ext).toHaveProperty("list");
722
+ expect(ext).toHaveProperty("remove");
723
+ expect(ext).toHaveProperty("mkdir");
724
+ });
725
+
726
+ it("returns metrics extension", () => {
727
+ const ext = provider.getExtension("abc123", "metrics");
728
+ expect(ext).toBeDefined();
729
+ expect(ext).toHaveProperty("getResourceUsage");
730
+ expect(ext).toHaveProperty("streamMetrics");
731
+ });
732
+ });
733
+
734
+ describe("detectGpu", () => {
735
+ it("detects nvidia runtime", async () => {
736
+ mocks.mockDocker.info.mockResolvedValueOnce({
737
+ Runtimes: { nvidia: {}, runc: {} },
738
+ });
739
+
740
+ const hasGpu = await provider.detectGpu();
741
+ expect(hasGpu).toBe(true);
742
+ expect(provider.capabilities.gpu).toBe(true);
743
+ });
744
+
745
+ it("returns false when no nvidia runtime", async () => {
746
+ mocks.mockDocker.info.mockResolvedValueOnce({
747
+ Runtimes: { runc: {} },
748
+ });
749
+
750
+ const hasGpu = await provider.detectGpu();
751
+ expect(hasGpu).toBe(false);
752
+ expect(provider.capabilities.gpu).toBe(false);
753
+ });
754
+
755
+ it("returns false on error", async () => {
756
+ mocks.mockDocker.info.mockRejectedValueOnce(new Error("unreachable"));
757
+
758
+ const hasGpu = await provider.detectGpu();
759
+ expect(hasGpu).toBe(false);
760
+ });
761
+ });
762
+ });