@wopr-network/platform-core 1.37.0 → 1.39.0
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.
- package/dist/fleet/fleet-manager-shared-volume.test.js +3 -1
- package/dist/fleet/fleet-manager.d.ts +9 -9
- package/dist/fleet/fleet-manager.js +75 -108
- package/dist/fleet/fleet-manager.test.js +37 -180
- package/dist/fleet/image-poller.test.js +70 -55
- package/dist/fleet/index.d.ts +1 -0
- package/dist/fleet/index.js +1 -0
- package/dist/fleet/instance.d.ts +54 -0
- package/dist/fleet/instance.js +136 -0
- package/dist/fleet/types.d.ts +4 -0
- package/dist/fleet/types.js +8 -0
- package/dist/fleet/updater.js +5 -14
- package/dist/fleet/updater.test.js +9 -18
- package/dist/node-agent/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/fleet/fleet-manager-shared-volume.test.ts +3 -1
- package/src/fleet/fleet-manager.test.ts +37 -220
- package/src/fleet/fleet-manager.ts +77 -102
- package/src/fleet/image-poller.test.ts +70 -55
- package/src/fleet/index.ts +1 -0
- package/src/fleet/instance.ts +171 -0
- package/src/fleet/types.ts +8 -0
- package/src/fleet/updater.test.ts +9 -18
- package/src/fleet/updater.ts +5 -16
|
@@ -2,7 +2,7 @@ import type Docker from "dockerode";
|
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { NetworkPolicy } from "../network/network-policy.js";
|
|
4
4
|
import type { IBotInstanceRepository } from "./bot-instance-repository.js";
|
|
5
|
-
import { BotNotFoundError, FleetManager
|
|
5
|
+
import { BotNotFoundError, FleetManager } from "./fleet-manager.js";
|
|
6
6
|
import type { INodeCommandBus } from "./node-command-bus.js";
|
|
7
7
|
import type { ProfileStore } from "./profile-store.js";
|
|
8
8
|
import type { BotInstance, NewBotInstance } from "./repository-types.js";
|
|
@@ -19,6 +19,7 @@ function mockContainer(overrides: Record<string, unknown> = {}) {
|
|
|
19
19
|
remove: vi.fn().mockResolvedValue(undefined),
|
|
20
20
|
inspect: vi.fn().mockResolvedValue({
|
|
21
21
|
Id: "container-123",
|
|
22
|
+
Name: "/wopr-test-bot",
|
|
22
23
|
Created: "2026-01-01T00:00:00Z",
|
|
23
24
|
State: {
|
|
24
25
|
Status: "running",
|
|
@@ -26,6 +27,7 @@ function mockContainer(overrides: Record<string, unknown> = {}) {
|
|
|
26
27
|
StartedAt: "2026-01-01T00:00:00Z",
|
|
27
28
|
Health: { Status: "healthy" },
|
|
28
29
|
},
|
|
30
|
+
NetworkSettings: { Ports: {} },
|
|
29
31
|
}),
|
|
30
32
|
stats: vi.fn().mockResolvedValue({
|
|
31
33
|
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
|
@@ -108,10 +110,10 @@ describe("FleetManager", () => {
|
|
|
108
110
|
|
|
109
111
|
describe("create", () => {
|
|
110
112
|
it("saves profile, pulls image, and creates container", async () => {
|
|
111
|
-
const
|
|
113
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
112
114
|
|
|
113
|
-
expect(
|
|
114
|
-
expect(profile.name).toBe("test-bot");
|
|
115
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
116
|
+
expect(instance.profile.name).toBe("test-bot");
|
|
115
117
|
expect(store.save).toHaveBeenCalledWith(expect.objectContaining({ name: "test-bot" }));
|
|
116
118
|
expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable", {});
|
|
117
119
|
expect(docker.createContainer).toHaveBeenCalledWith(
|
|
@@ -140,94 +142,19 @@ describe("FleetManager", () => {
|
|
|
140
142
|
});
|
|
141
143
|
|
|
142
144
|
describe("start", () => {
|
|
143
|
-
it("starts
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Id: "container-123",
|
|
147
|
-
Created: "2026-01-01T00:00:00Z",
|
|
148
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
|
|
149
|
-
});
|
|
150
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
151
|
-
|
|
152
|
-
await fleet.start("bot-id");
|
|
153
|
-
expect(container.start).toHaveBeenCalled();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("starts an exited container", async () => {
|
|
157
|
-
container.inspect.mockResolvedValue({
|
|
158
|
-
Id: "container-123",
|
|
159
|
-
Created: "2026-01-01T00:00:00Z",
|
|
160
|
-
State: { Status: "exited", Running: false, StartedAt: "", Health: null },
|
|
161
|
-
});
|
|
162
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
163
|
-
|
|
164
|
-
await fleet.start("bot-id");
|
|
145
|
+
it("starts a created container via Instance.start()", async () => {
|
|
146
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
147
|
+
await instance.start();
|
|
165
148
|
expect(container.start).toHaveBeenCalled();
|
|
166
149
|
});
|
|
167
|
-
|
|
168
|
-
it("starts a dead container", async () => {
|
|
169
|
-
container.inspect.mockResolvedValue({
|
|
170
|
-
Id: "container-123",
|
|
171
|
-
Created: "2026-01-01T00:00:00Z",
|
|
172
|
-
State: { Status: "dead", Running: false, StartedAt: "", Health: null },
|
|
173
|
-
});
|
|
174
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
175
|
-
|
|
176
|
-
await fleet.start("bot-id");
|
|
177
|
-
expect(container.start).toHaveBeenCalled();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("throws InvalidStateTransitionError with 'unknown' when Status is missing/null", async () => {
|
|
181
|
-
container.inspect.mockResolvedValue({
|
|
182
|
-
Id: "container-123",
|
|
183
|
-
Created: "2026-01-01T00:00:00Z",
|
|
184
|
-
State: { Status: null, Running: false, StartedAt: "", Health: null },
|
|
185
|
-
});
|
|
186
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
187
|
-
|
|
188
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
189
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(/unknown/);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("throws InvalidStateTransitionError when container is already running", async () => {
|
|
193
|
-
// Default mockContainer has state "running"
|
|
194
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
195
|
-
|
|
196
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
197
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(/Cannot start bot/);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("throws BotNotFoundError when container not found", async () => {
|
|
201
|
-
docker.listContainers.mockResolvedValue([]);
|
|
202
|
-
await expect(fleet.start("missing")).rejects.toThrow(BotNotFoundError);
|
|
203
|
-
});
|
|
204
150
|
});
|
|
205
151
|
|
|
206
152
|
describe("stop", () => {
|
|
207
|
-
it("stops a running container", async () => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
await fleet.stop("bot-id");
|
|
153
|
+
it("stops a running container via Instance.stop()", async () => {
|
|
154
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
155
|
+
await instance.stop();
|
|
212
156
|
expect(container.stop).toHaveBeenCalled();
|
|
213
157
|
});
|
|
214
|
-
|
|
215
|
-
it("throws InvalidStateTransitionError when container is already stopped", async () => {
|
|
216
|
-
container.inspect.mockResolvedValue({
|
|
217
|
-
Id: "container-123",
|
|
218
|
-
Created: "2026-01-01T00:00:00Z",
|
|
219
|
-
State: { Status: "exited", Running: false, StartedAt: "", Health: null },
|
|
220
|
-
});
|
|
221
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
222
|
-
|
|
223
|
-
await expect(fleet.stop("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
224
|
-
await expect(fleet.stop("bot-id")).rejects.toThrow(/Cannot stop bot/);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("throws BotNotFoundError when container not found", async () => {
|
|
228
|
-
docker.listContainers.mockResolvedValue([]);
|
|
229
|
-
await expect(fleet.stop("missing")).rejects.toThrow(BotNotFoundError);
|
|
230
|
-
});
|
|
231
158
|
});
|
|
232
159
|
|
|
233
160
|
describe("restart", () => {
|
|
@@ -521,10 +448,11 @@ describe("FleetManager", () => {
|
|
|
521
448
|
);
|
|
522
449
|
});
|
|
523
450
|
|
|
524
|
-
it("calls addRoute
|
|
525
|
-
const
|
|
451
|
+
it("calls addRoute via setupProxy with correct subdomain and upstream", async () => {
|
|
452
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
453
|
+
await instance.setupProxy();
|
|
526
454
|
expect(proxyManager.addRoute).toHaveBeenCalledWith({
|
|
527
|
-
instanceId:
|
|
455
|
+
instanceId: instance.id,
|
|
528
456
|
subdomain: "test-bot",
|
|
529
457
|
upstreamHost: "wopr-test-bot",
|
|
530
458
|
upstreamPort: 7437,
|
|
@@ -532,11 +460,12 @@ describe("FleetManager", () => {
|
|
|
532
460
|
});
|
|
533
461
|
});
|
|
534
462
|
|
|
535
|
-
it("still returns
|
|
463
|
+
it("still returns instance when setupProxy addRoute fails (non-fatal)", async () => {
|
|
464
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
536
465
|
proxyManager.addRoute.mockRejectedValueOnce(new Error("DNS fail"));
|
|
537
|
-
|
|
538
|
-
expect(
|
|
539
|
-
expect(profile.name).toBe("test-bot");
|
|
466
|
+
await instance.setupProxy();
|
|
467
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
468
|
+
expect(instance.profile.name).toBe("test-bot");
|
|
540
469
|
});
|
|
541
470
|
|
|
542
471
|
it("calls removeRoute on remove", async () => {
|
|
@@ -546,36 +475,33 @@ describe("FleetManager", () => {
|
|
|
546
475
|
expect(proxyManager.removeRoute).toHaveBeenCalledWith("bot-id");
|
|
547
476
|
});
|
|
548
477
|
|
|
549
|
-
it("
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
|
|
554
|
-
});
|
|
555
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
556
|
-
await proxyFleet.start("bot-id");
|
|
557
|
-
expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", true);
|
|
478
|
+
it("starts via Instance.start()", async () => {
|
|
479
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
480
|
+
await instance.start();
|
|
481
|
+
expect(container.start).toHaveBeenCalled();
|
|
558
482
|
});
|
|
559
483
|
|
|
560
|
-
it("
|
|
561
|
-
|
|
562
|
-
await
|
|
563
|
-
expect(
|
|
484
|
+
it("stops via Instance.stop()", async () => {
|
|
485
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
486
|
+
await instance.stop();
|
|
487
|
+
expect(container.stop).toHaveBeenCalled();
|
|
564
488
|
});
|
|
565
489
|
|
|
566
490
|
it("does not call proxy methods when no proxyManager is provided", async () => {
|
|
567
491
|
// `fleet` from the outer beforeEach has no proxyManager
|
|
568
|
-
const
|
|
492
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
493
|
+
await instance.setupProxy(); // no-op since no proxyManager
|
|
569
494
|
expect(proxyManager.addRoute).not.toHaveBeenCalled();
|
|
570
|
-
expect(
|
|
495
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
571
496
|
});
|
|
572
497
|
|
|
573
498
|
it("normalizes underscores to hyphens in subdomain", async () => {
|
|
574
|
-
await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
|
|
499
|
+
const instance = await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
|
|
500
|
+
await instance.setupProxy();
|
|
575
501
|
expect(proxyManager.addRoute).toHaveBeenCalledWith(
|
|
576
502
|
expect.objectContaining({
|
|
577
503
|
subdomain: "my-cool-bot",
|
|
578
|
-
upstreamHost: "wopr-
|
|
504
|
+
upstreamHost: "wopr-test-bot",
|
|
579
505
|
}),
|
|
580
506
|
);
|
|
581
507
|
});
|
|
@@ -646,76 +572,6 @@ describe("FleetManager", () => {
|
|
|
646
572
|
});
|
|
647
573
|
|
|
648
574
|
describe("per-bot mutex", () => {
|
|
649
|
-
it("serializes 5 concurrent startBot calls into exactly 1 start per call", async () => {
|
|
650
|
-
// Containers must be in stopped state so the state guard passes
|
|
651
|
-
container.inspect.mockResolvedValue({
|
|
652
|
-
Id: "container-123",
|
|
653
|
-
Created: "2026-01-01T00:00:00Z",
|
|
654
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
|
|
655
|
-
});
|
|
656
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
657
|
-
|
|
658
|
-
let startCount = 0;
|
|
659
|
-
container.start.mockImplementation(async () => {
|
|
660
|
-
startCount++;
|
|
661
|
-
const current = startCount;
|
|
662
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
663
|
-
expect(startCount).toBe(current);
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
const promises = Array.from({ length: 5 }, () => fleet.start("bot-id"));
|
|
667
|
-
await Promise.all(promises);
|
|
668
|
-
|
|
669
|
-
expect(container.start).toHaveBeenCalledTimes(5);
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it("does not block operations on different bots (proves concurrency)", async () => {
|
|
673
|
-
// For this test: start needs stopped state, stop needs running state.
|
|
674
|
-
// Both bot-a and bot-b share the same mock container.
|
|
675
|
-
// Use a stopped state so start() passes, and mock stop() directly so it bypasses state check.
|
|
676
|
-
container.inspect.mockResolvedValue({
|
|
677
|
-
Id: "container-123",
|
|
678
|
-
Created: "2026-01-01T00:00:00Z",
|
|
679
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
|
|
680
|
-
});
|
|
681
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
682
|
-
|
|
683
|
-
let releaseStart!: () => void;
|
|
684
|
-
container.start.mockImplementation(
|
|
685
|
-
() =>
|
|
686
|
-
new Promise<void>((resolve) => {
|
|
687
|
-
releaseStart = resolve;
|
|
688
|
-
}),
|
|
689
|
-
);
|
|
690
|
-
const stopSpy = vi.fn();
|
|
691
|
-
// Override inspect for the stop path to return "running" state
|
|
692
|
-
let callCount = 0;
|
|
693
|
-
container.inspect.mockImplementation(async () => {
|
|
694
|
-
callCount++;
|
|
695
|
-
// First call is for bot-a start, subsequent for bot-b stop
|
|
696
|
-
return callCount === 1
|
|
697
|
-
? { Id: "container-123", State: { Status: "stopped", Running: false } }
|
|
698
|
-
: { Id: "container-123", State: { Status: "running", Running: true } };
|
|
699
|
-
});
|
|
700
|
-
container.stop.mockImplementation(async () => {
|
|
701
|
-
stopSpy();
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
const startPromise = fleet.start("bot-a");
|
|
705
|
-
await Promise.resolve(); // allow start lock acquisition
|
|
706
|
-
await expect(fleet.stop("bot-b")).resolves.toBeUndefined();
|
|
707
|
-
expect(stopSpy).toHaveBeenCalledTimes(1);
|
|
708
|
-
releaseStart();
|
|
709
|
-
await startPromise;
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
it("releases lock even when operation throws", async () => {
|
|
713
|
-
docker.listContainers.mockResolvedValue([]);
|
|
714
|
-
|
|
715
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
|
|
716
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
|
|
717
|
-
});
|
|
718
|
-
|
|
719
575
|
it("serializes concurrent create calls with the same explicit ID (mutual exclusion)", async () => {
|
|
720
576
|
// Add a delay to store.save so the race is observable: the mutex must ensure
|
|
721
577
|
// the first save completes before the second call checks for the existing profile.
|
|
@@ -800,7 +656,7 @@ describe("FleetManager", () => {
|
|
|
800
656
|
instanceRepo,
|
|
801
657
|
);
|
|
802
658
|
|
|
803
|
-
const
|
|
659
|
+
const instance = await fleet.create({
|
|
804
660
|
id: botId,
|
|
805
661
|
tenantId: "t1",
|
|
806
662
|
name: "test-bot",
|
|
@@ -812,7 +668,7 @@ describe("FleetManager", () => {
|
|
|
812
668
|
updatePolicy: "manual",
|
|
813
669
|
});
|
|
814
670
|
|
|
815
|
-
expect(
|
|
671
|
+
expect(instance.id).toBe(botId);
|
|
816
672
|
expect(bus.send).toHaveBeenCalledWith("node-1", {
|
|
817
673
|
type: "bot.start",
|
|
818
674
|
payload: {
|
|
@@ -862,45 +718,6 @@ describe("FleetManager", () => {
|
|
|
862
718
|
expect(docker.pull).toHaveBeenCalled();
|
|
863
719
|
});
|
|
864
720
|
|
|
865
|
-
it("should dispatch stop via commandBus when bot has nodeId", async () => {
|
|
866
|
-
const container = mockContainer();
|
|
867
|
-
const docker = mockDocker(container);
|
|
868
|
-
const store = mockStore();
|
|
869
|
-
const bus = mockCommandBus();
|
|
870
|
-
const instanceRepo = mockInstanceRepo();
|
|
871
|
-
|
|
872
|
-
const botId = "bot-stop-1";
|
|
873
|
-
await instanceRepo.create({ id: botId, tenantId: "t1", name: "stop-bot", nodeId: "node-2" });
|
|
874
|
-
|
|
875
|
-
const fleet = new FleetManager(
|
|
876
|
-
docker as unknown as Docker,
|
|
877
|
-
store,
|
|
878
|
-
undefined,
|
|
879
|
-
undefined,
|
|
880
|
-
undefined,
|
|
881
|
-
bus,
|
|
882
|
-
instanceRepo,
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
await store.save({
|
|
886
|
-
id: botId,
|
|
887
|
-
tenantId: "t1",
|
|
888
|
-
name: "stop-bot",
|
|
889
|
-
description: "",
|
|
890
|
-
image: "ghcr.io/wopr-network/wopr:latest",
|
|
891
|
-
env: {},
|
|
892
|
-
restartPolicy: "unless-stopped",
|
|
893
|
-
} as BotProfile);
|
|
894
|
-
|
|
895
|
-
await fleet.stop(botId);
|
|
896
|
-
|
|
897
|
-
expect(bus.send).toHaveBeenCalledWith("node-2", {
|
|
898
|
-
type: "bot.stop",
|
|
899
|
-
payload: { name: "stop-bot" },
|
|
900
|
-
});
|
|
901
|
-
expect(container.stop).not.toHaveBeenCalled();
|
|
902
|
-
});
|
|
903
|
-
|
|
904
721
|
it("should dispatch restart via commandBus when bot has nodeId", async () => {
|
|
905
722
|
const container = mockContainer();
|
|
906
723
|
const docker = mockDocker(container);
|
|
@@ -10,6 +10,7 @@ import type { NetworkPolicy } from "../network/network-policy.js";
|
|
|
10
10
|
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
11
11
|
import type { IBotInstanceRepository } from "./bot-instance-repository.js";
|
|
12
12
|
import type { BotEventType, FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
13
|
+
import { Instance } from "./instance.js";
|
|
13
14
|
import type { INodeCommandBus } from "./node-command-bus.js";
|
|
14
15
|
import type { IProfileStore } from "./profile-store.js";
|
|
15
16
|
import { getSharedVolumeConfig } from "./shared-volume-config.js";
|
|
@@ -98,10 +99,10 @@ export class FleetManager {
|
|
|
98
99
|
async create(
|
|
99
100
|
params: Omit<BotProfile, "id"> & { id?: string },
|
|
100
101
|
resourceLimits?: ContainerResourceLimits,
|
|
101
|
-
): Promise<
|
|
102
|
+
): Promise<Instance> {
|
|
102
103
|
const id = params.id ?? randomUUID();
|
|
103
104
|
const hasExplicitId = "id" in params && params.id !== undefined;
|
|
104
|
-
const doCreate = async () => {
|
|
105
|
+
const doCreate = async (): Promise<Instance> => {
|
|
105
106
|
const profile: BotProfile = { ...params, id };
|
|
106
107
|
|
|
107
108
|
if (hasExplicitId && (await this.store.get(id))) {
|
|
@@ -113,7 +114,6 @@ export class FleetManager {
|
|
|
113
114
|
try {
|
|
114
115
|
const remote = await this.resolveNodeId(id);
|
|
115
116
|
if (remote) {
|
|
116
|
-
// Dispatch to remote node agent — it handles pull + create + start
|
|
117
117
|
await remote.commandBus.send(remote.nodeId, {
|
|
118
118
|
type: "bot.start",
|
|
119
119
|
payload: {
|
|
@@ -123,8 +123,21 @@ export class FleetManager {
|
|
|
123
123
|
restart: profile.restartPolicy,
|
|
124
124
|
},
|
|
125
125
|
});
|
|
126
|
+
// Remote bots have no local container — return a remote Instance
|
|
127
|
+
const containerName = `wopr-${profile.name.replace(/_/g, "-")}`;
|
|
128
|
+
const remoteInstance = new Instance({
|
|
129
|
+
docker: this.docker,
|
|
130
|
+
profile,
|
|
131
|
+
containerId: `remote:${remote.nodeId}`,
|
|
132
|
+
containerName,
|
|
133
|
+
url: `remote://${remote.nodeId}/${containerName}`,
|
|
134
|
+
instanceRepo: this.instanceRepo,
|
|
135
|
+
proxyManager: this.proxyManager,
|
|
136
|
+
eventEmitter: this.eventEmitter,
|
|
137
|
+
});
|
|
138
|
+
remoteInstance.emitCreated();
|
|
139
|
+
return remoteInstance;
|
|
126
140
|
} else {
|
|
127
|
-
// Local dockerode fallback
|
|
128
141
|
await this.pullImage(profile.image);
|
|
129
142
|
await this.createContainer(profile, resourceLimits);
|
|
130
143
|
}
|
|
@@ -136,106 +149,60 @@ export class FleetManager {
|
|
|
136
149
|
throw err;
|
|
137
150
|
}
|
|
138
151
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const subdomain = profile.name.toLowerCase().replace(/_/g, "-");
|
|
143
|
-
await this.proxyManager.addRoute({
|
|
144
|
-
instanceId: profile.id,
|
|
145
|
-
subdomain,
|
|
146
|
-
upstreamHost: `wopr-${subdomain}`,
|
|
147
|
-
upstreamPort: 7437,
|
|
148
|
-
healthy: true,
|
|
149
|
-
});
|
|
150
|
-
} catch (err) {
|
|
151
|
-
logger.warn("Proxy route registration failed (non-fatal)", { botId: profile.id, err });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
this.emitEvent("bot.created", profile.id, profile.tenantId);
|
|
156
|
-
return profile;
|
|
152
|
+
const instance = await this.buildInstance(profile);
|
|
153
|
+
instance.emitCreated();
|
|
154
|
+
return instance;
|
|
157
155
|
};
|
|
158
156
|
|
|
159
157
|
return hasExplicitId ? this.withLock(id, doCreate) : doCreate();
|
|
160
158
|
}
|
|
161
159
|
|
|
162
160
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* Throws InvalidStateTransitionError if the container is already running.
|
|
161
|
+
* Build an Instance from a profile after container creation.
|
|
162
|
+
* Inspects the Docker container to resolve container name and URL.
|
|
166
163
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const remote = await this.resolveNodeId(id);
|
|
171
|
-
if (remote) {
|
|
172
|
-
const profile = await this.store.get(id);
|
|
173
|
-
if (!profile) throw new BotNotFoundError(id);
|
|
174
|
-
await remote.commandBus.send(remote.nodeId, {
|
|
175
|
-
type: "bot.start",
|
|
176
|
-
payload: {
|
|
177
|
-
name: profile.name,
|
|
178
|
-
image: profile.image,
|
|
179
|
-
env: profile.env,
|
|
180
|
-
restart: profile.restartPolicy,
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
} else {
|
|
184
|
-
const container = await this.findContainer(id);
|
|
185
|
-
if (!container) throw new BotNotFoundError(id);
|
|
186
|
-
const info = await container.inspect();
|
|
187
|
-
const validStartStates = new Set(["stopped", "created", "exited", "dead", "error"]);
|
|
188
|
-
this.assertValidState(id, info.State.Status, "start", validStartStates);
|
|
189
|
-
await container.start();
|
|
190
|
-
}
|
|
191
|
-
if (this.proxyManager) {
|
|
192
|
-
this.proxyManager.updateHealth(id, true);
|
|
193
|
-
}
|
|
194
|
-
logger.info(`Started bot ${id}`);
|
|
195
|
-
let startedTenantId: string | undefined;
|
|
196
|
-
try {
|
|
197
|
-
startedTenantId = (await this.store.get(id))?.tenantId;
|
|
198
|
-
} catch (err) {
|
|
199
|
-
logger.warn(`Failed to fetch profile after starting bot ${id}`, { err });
|
|
200
|
-
}
|
|
201
|
-
this.emitEvent("bot.started", id, startedTenantId);
|
|
202
|
-
});
|
|
164
|
+
private resolvePort(profile: BotProfile): number {
|
|
165
|
+
const envPort = profile.env?.PORT;
|
|
166
|
+
return envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
|
|
203
167
|
}
|
|
204
168
|
|
|
205
169
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* Throws InvalidStateTransitionError if the container is not running.
|
|
170
|
+
* Get an Instance handle for an existing bot by ID.
|
|
171
|
+
* Looks up the profile and inspects the Docker container.
|
|
209
172
|
*/
|
|
210
|
-
async
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
173
|
+
async getInstance(id: string): Promise<Instance> {
|
|
174
|
+
const profile = await this.store.get(id);
|
|
175
|
+
if (!profile) throw new BotNotFoundError(id);
|
|
176
|
+
return this.buildInstance(profile);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async buildInstance(profile: BotProfile): Promise<Instance> {
|
|
180
|
+
const dockerContainer = await this.findContainer(profile.id);
|
|
181
|
+
if (!dockerContainer) throw new Error(`Container for ${profile.id} not found after creation`);
|
|
182
|
+
const info = await dockerContainer.inspect();
|
|
183
|
+
const containerName = info.Name.replace(/^\//, "");
|
|
184
|
+
const containerId = info.Id;
|
|
185
|
+
|
|
186
|
+
// Resolve URL from network DNS or host port mapping
|
|
187
|
+
let url: string;
|
|
188
|
+
const port = this.resolvePort(profile);
|
|
189
|
+
if (profile.network) {
|
|
190
|
+
url = `http://${containerName}:${port}`;
|
|
191
|
+
} else {
|
|
192
|
+
const portBindings = info.NetworkSettings?.Ports?.[`${port}/tcp`];
|
|
193
|
+
const hostPort = portBindings?.[0]?.HostPort ?? String(port);
|
|
194
|
+
url = `http://localhost:${hostPort}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return new Instance({
|
|
198
|
+
docker: this.docker,
|
|
199
|
+
profile,
|
|
200
|
+
containerId,
|
|
201
|
+
containerName,
|
|
202
|
+
url,
|
|
203
|
+
instanceRepo: this.instanceRepo,
|
|
204
|
+
proxyManager: this.proxyManager,
|
|
205
|
+
eventEmitter: this.eventEmitter,
|
|
239
206
|
});
|
|
240
207
|
}
|
|
241
208
|
|
|
@@ -433,6 +400,8 @@ export class FleetManager {
|
|
|
433
400
|
"volumeName",
|
|
434
401
|
"name",
|
|
435
402
|
"discovery",
|
|
403
|
+
"network",
|
|
404
|
+
"ephemeral",
|
|
436
405
|
]);
|
|
437
406
|
|
|
438
407
|
/**
|
|
@@ -608,23 +577,29 @@ export class FleetManager {
|
|
|
608
577
|
binds.push(`${sharedVolConfig.volumeName}:${sharedVolConfig.mountPath}:ro`);
|
|
609
578
|
}
|
|
610
579
|
|
|
580
|
+
const isEphemeral = profile.ephemeral === true;
|
|
581
|
+
|
|
611
582
|
const hostConfig: Docker.ContainerCreateOptions["HostConfig"] = {
|
|
612
583
|
RestartPolicy: {
|
|
613
584
|
Name: restartPolicyMap[profile.restartPolicy] || "",
|
|
614
585
|
},
|
|
615
586
|
Binds: binds.length > 0 ? binds : undefined,
|
|
616
587
|
SecurityOpt: ["no-new-privileges"],
|
|
617
|
-
CapDrop: ["ALL"],
|
|
618
|
-
CapAdd: ["NET_BIND_SERVICE"],
|
|
619
|
-
ReadonlyRootfs:
|
|
620
|
-
Tmpfs:
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
588
|
+
CapDrop: isEphemeral ? undefined : ["ALL"],
|
|
589
|
+
CapAdd: isEphemeral ? undefined : ["NET_BIND_SERVICE"],
|
|
590
|
+
ReadonlyRootfs: !isEphemeral,
|
|
591
|
+
Tmpfs: isEphemeral
|
|
592
|
+
? undefined
|
|
593
|
+
: {
|
|
594
|
+
"/tmp": "rw,noexec,nosuid,size=64m",
|
|
595
|
+
"/var/tmp": "rw,noexec,nosuid,size=64m",
|
|
596
|
+
},
|
|
624
597
|
};
|
|
625
598
|
|
|
626
|
-
// Set
|
|
627
|
-
if (
|
|
599
|
+
// Set network: explicit profile.network takes precedence, then NetworkPolicy
|
|
600
|
+
if (profile.network) {
|
|
601
|
+
hostConfig.NetworkMode = profile.network;
|
|
602
|
+
} else if (this.networkPolicy) {
|
|
628
603
|
const networkMode = await this.networkPolicy.prepareForContainer(profile.tenantId);
|
|
629
604
|
hostConfig.NetworkMode = networkMode;
|
|
630
605
|
}
|