@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
|
@@ -9,8 +9,10 @@ function mockContainer() {
|
|
|
9
9
|
remove: vi.fn().mockResolvedValue(undefined),
|
|
10
10
|
inspect: vi.fn().mockResolvedValue({
|
|
11
11
|
Id: "container-123",
|
|
12
|
+
Name: "/wopr-test",
|
|
12
13
|
Created: "2026-01-01T00:00:00Z",
|
|
13
14
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z" },
|
|
15
|
+
NetworkSettings: { Ports: {} },
|
|
14
16
|
}),
|
|
15
17
|
stats: vi.fn().mockResolvedValue({
|
|
16
18
|
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
|
@@ -24,7 +26,7 @@ function makeDocker() {
|
|
|
24
26
|
return {
|
|
25
27
|
pull: vi.fn().mockResolvedValue("stream"),
|
|
26
28
|
createContainer: vi.fn().mockResolvedValue(mockContainer()),
|
|
27
|
-
listContainers: vi.fn().mockResolvedValue([]),
|
|
29
|
+
listContainers: vi.fn().mockResolvedValue([{ Id: "container-123" }]),
|
|
28
30
|
getContainer: vi.fn().mockReturnValue(mockContainer()),
|
|
29
31
|
modem: {
|
|
30
32
|
followProgress: vi.fn((_stream, cb) => cb(null)),
|
|
@@ -6,6 +6,7 @@ import type { NetworkPolicy } from "../network/network-policy.js";
|
|
|
6
6
|
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
7
7
|
import type { IBotInstanceRepository } from "./bot-instance-repository.js";
|
|
8
8
|
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
9
|
+
import { Instance } from "./instance.js";
|
|
9
10
|
import type { INodeCommandBus } from "./node-command-bus.js";
|
|
10
11
|
import type { IProfileStore } from "./profile-store.js";
|
|
11
12
|
import type { BotProfile, BotStatus } from "./types.js";
|
|
@@ -40,19 +41,18 @@ export declare class FleetManager {
|
|
|
40
41
|
*/
|
|
41
42
|
create(params: Omit<BotProfile, "id"> & {
|
|
42
43
|
id?: string;
|
|
43
|
-
}, resourceLimits?: ContainerResourceLimits): Promise<
|
|
44
|
+
}, resourceLimits?: ContainerResourceLimits): Promise<Instance>;
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* Throws InvalidStateTransitionError if the container is already running.
|
|
46
|
+
* Build an Instance from a profile after container creation.
|
|
47
|
+
* Inspects the Docker container to resolve container name and URL.
|
|
48
48
|
*/
|
|
49
|
-
|
|
49
|
+
private resolvePort;
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* Throws InvalidStateTransitionError if the container is not running.
|
|
51
|
+
* Get an Instance handle for an existing bot by ID.
|
|
52
|
+
* Looks up the profile and inspects the Docker container.
|
|
54
53
|
*/
|
|
55
|
-
|
|
54
|
+
getInstance(id: string): Promise<Instance>;
|
|
55
|
+
private buildInstance;
|
|
56
56
|
/**
|
|
57
57
|
* Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
|
|
58
58
|
* Valid from: running, stopped, exited, dead states.
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { PassThrough } from "node:stream";
|
|
3
3
|
import { logger } from "../config/logger.js";
|
|
4
4
|
import { buildDiscoveryEnv } from "../discovery/discovery-config.js";
|
|
5
|
+
import { Instance } from "./instance.js";
|
|
5
6
|
import { getSharedVolumeConfig } from "./shared-volume-config.js";
|
|
6
7
|
const CONTAINER_LABEL = "wopr.managed";
|
|
7
8
|
const CONTAINER_ID_LABEL = "wopr.bot-id";
|
|
@@ -85,7 +86,6 @@ export class FleetManager {
|
|
|
85
86
|
try {
|
|
86
87
|
const remote = await this.resolveNodeId(id);
|
|
87
88
|
if (remote) {
|
|
88
|
-
// Dispatch to remote node agent — it handles pull + create + start
|
|
89
89
|
await remote.commandBus.send(remote.nodeId, {
|
|
90
90
|
type: "bot.start",
|
|
91
91
|
payload: {
|
|
@@ -95,9 +95,22 @@ export class FleetManager {
|
|
|
95
95
|
restart: profile.restartPolicy,
|
|
96
96
|
},
|
|
97
97
|
});
|
|
98
|
+
// Remote bots have no local container — return a remote Instance
|
|
99
|
+
const containerName = `wopr-${profile.name.replace(/_/g, "-")}`;
|
|
100
|
+
const remoteInstance = new Instance({
|
|
101
|
+
docker: this.docker,
|
|
102
|
+
profile,
|
|
103
|
+
containerId: `remote:${remote.nodeId}`,
|
|
104
|
+
containerName,
|
|
105
|
+
url: `remote://${remote.nodeId}/${containerName}`,
|
|
106
|
+
instanceRepo: this.instanceRepo,
|
|
107
|
+
proxyManager: this.proxyManager,
|
|
108
|
+
eventEmitter: this.eventEmitter,
|
|
109
|
+
});
|
|
110
|
+
remoteInstance.emitCreated();
|
|
111
|
+
return remoteInstance;
|
|
98
112
|
}
|
|
99
113
|
else {
|
|
100
|
-
// Local dockerode fallback
|
|
101
114
|
await this.pullImage(profile.image);
|
|
102
115
|
await this.createContainer(profile, resourceLimits);
|
|
103
116
|
}
|
|
@@ -109,111 +122,57 @@ export class FleetManager {
|
|
|
109
122
|
await this.store.delete(profile.id);
|
|
110
123
|
throw err;
|
|
111
124
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const subdomain = profile.name.toLowerCase().replace(/_/g, "-");
|
|
116
|
-
await this.proxyManager.addRoute({
|
|
117
|
-
instanceId: profile.id,
|
|
118
|
-
subdomain,
|
|
119
|
-
upstreamHost: `wopr-${subdomain}`,
|
|
120
|
-
upstreamPort: 7437,
|
|
121
|
-
healthy: true,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
logger.warn("Proxy route registration failed (non-fatal)", { botId: profile.id, err });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
this.emitEvent("bot.created", profile.id, profile.tenantId);
|
|
129
|
-
return profile;
|
|
125
|
+
const instance = await this.buildInstance(profile);
|
|
126
|
+
instance.emitCreated();
|
|
127
|
+
return instance;
|
|
130
128
|
};
|
|
131
129
|
return hasExplicitId ? this.withLock(id, doCreate) : doCreate();
|
|
132
130
|
}
|
|
133
131
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* Throws InvalidStateTransitionError if the container is already running.
|
|
132
|
+
* Build an Instance from a profile after container creation.
|
|
133
|
+
* Inspects the Docker container to resolve container name and URL.
|
|
137
134
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const remote = await this.resolveNodeId(id);
|
|
142
|
-
if (remote) {
|
|
143
|
-
const profile = await this.store.get(id);
|
|
144
|
-
if (!profile)
|
|
145
|
-
throw new BotNotFoundError(id);
|
|
146
|
-
await remote.commandBus.send(remote.nodeId, {
|
|
147
|
-
type: "bot.start",
|
|
148
|
-
payload: {
|
|
149
|
-
name: profile.name,
|
|
150
|
-
image: profile.image,
|
|
151
|
-
env: profile.env,
|
|
152
|
-
restart: profile.restartPolicy,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
const container = await this.findContainer(id);
|
|
158
|
-
if (!container)
|
|
159
|
-
throw new BotNotFoundError(id);
|
|
160
|
-
const info = await container.inspect();
|
|
161
|
-
const validStartStates = new Set(["stopped", "created", "exited", "dead", "error"]);
|
|
162
|
-
this.assertValidState(id, info.State.Status, "start", validStartStates);
|
|
163
|
-
await container.start();
|
|
164
|
-
}
|
|
165
|
-
if (this.proxyManager) {
|
|
166
|
-
this.proxyManager.updateHealth(id, true);
|
|
167
|
-
}
|
|
168
|
-
logger.info(`Started bot ${id}`);
|
|
169
|
-
let startedTenantId;
|
|
170
|
-
try {
|
|
171
|
-
startedTenantId = (await this.store.get(id))?.tenantId;
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
logger.warn(`Failed to fetch profile after starting bot ${id}`, { err });
|
|
175
|
-
}
|
|
176
|
-
this.emitEvent("bot.started", id, startedTenantId);
|
|
177
|
-
});
|
|
135
|
+
resolvePort(profile) {
|
|
136
|
+
const envPort = profile.env?.PORT;
|
|
137
|
+
return envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
|
|
178
138
|
}
|
|
179
139
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* Throws InvalidStateTransitionError if the container is not running.
|
|
140
|
+
* Get an Instance handle for an existing bot by ID.
|
|
141
|
+
* Looks up the profile and inspects the Docker container.
|
|
183
142
|
*/
|
|
184
|
-
async
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.
|
|
143
|
+
async getInstance(id) {
|
|
144
|
+
const profile = await this.store.get(id);
|
|
145
|
+
if (!profile)
|
|
146
|
+
throw new BotNotFoundError(id);
|
|
147
|
+
return this.buildInstance(profile);
|
|
148
|
+
}
|
|
149
|
+
async buildInstance(profile) {
|
|
150
|
+
const dockerContainer = await this.findContainer(profile.id);
|
|
151
|
+
if (!dockerContainer)
|
|
152
|
+
throw new Error(`Container for ${profile.id} not found after creation`);
|
|
153
|
+
const info = await dockerContainer.inspect();
|
|
154
|
+
const containerName = info.Name.replace(/^\//, "");
|
|
155
|
+
const containerId = info.Id;
|
|
156
|
+
// Resolve URL from network DNS or host port mapping
|
|
157
|
+
let url;
|
|
158
|
+
const port = this.resolvePort(profile);
|
|
159
|
+
if (profile.network) {
|
|
160
|
+
url = `http://${containerName}:${port}`;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const portBindings = info.NetworkSettings?.Ports?.[`${port}/tcp`];
|
|
164
|
+
const hostPort = portBindings?.[0]?.HostPort ?? String(port);
|
|
165
|
+
url = `http://localhost:${hostPort}`;
|
|
166
|
+
}
|
|
167
|
+
return new Instance({
|
|
168
|
+
docker: this.docker,
|
|
169
|
+
profile,
|
|
170
|
+
containerId,
|
|
171
|
+
containerName,
|
|
172
|
+
url,
|
|
173
|
+
instanceRepo: this.instanceRepo,
|
|
174
|
+
proxyManager: this.proxyManager,
|
|
175
|
+
eventEmitter: this.eventEmitter,
|
|
217
176
|
});
|
|
218
177
|
}
|
|
219
178
|
/**
|
|
@@ -397,6 +356,8 @@ export class FleetManager {
|
|
|
397
356
|
"volumeName",
|
|
398
357
|
"name",
|
|
399
358
|
"discovery",
|
|
359
|
+
"network",
|
|
360
|
+
"ephemeral",
|
|
400
361
|
]);
|
|
401
362
|
/**
|
|
402
363
|
* Update a bot profile. Only recreates the container if container-relevant
|
|
@@ -557,22 +518,28 @@ export class FleetManager {
|
|
|
557
518
|
if (sharedVolConfig.enabled) {
|
|
558
519
|
binds.push(`${sharedVolConfig.volumeName}:${sharedVolConfig.mountPath}:ro`);
|
|
559
520
|
}
|
|
521
|
+
const isEphemeral = profile.ephemeral === true;
|
|
560
522
|
const hostConfig = {
|
|
561
523
|
RestartPolicy: {
|
|
562
524
|
Name: restartPolicyMap[profile.restartPolicy] || "",
|
|
563
525
|
},
|
|
564
526
|
Binds: binds.length > 0 ? binds : undefined,
|
|
565
527
|
SecurityOpt: ["no-new-privileges"],
|
|
566
|
-
CapDrop: ["ALL"],
|
|
567
|
-
CapAdd: ["NET_BIND_SERVICE"],
|
|
568
|
-
ReadonlyRootfs:
|
|
569
|
-
Tmpfs:
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
528
|
+
CapDrop: isEphemeral ? undefined : ["ALL"],
|
|
529
|
+
CapAdd: isEphemeral ? undefined : ["NET_BIND_SERVICE"],
|
|
530
|
+
ReadonlyRootfs: !isEphemeral,
|
|
531
|
+
Tmpfs: isEphemeral
|
|
532
|
+
? undefined
|
|
533
|
+
: {
|
|
534
|
+
"/tmp": "rw,noexec,nosuid,size=64m",
|
|
535
|
+
"/var/tmp": "rw,noexec,nosuid,size=64m",
|
|
536
|
+
},
|
|
573
537
|
};
|
|
574
|
-
// Set
|
|
575
|
-
if (
|
|
538
|
+
// Set network: explicit profile.network takes precedence, then NetworkPolicy
|
|
539
|
+
if (profile.network) {
|
|
540
|
+
hostConfig.NetworkMode = profile.network;
|
|
541
|
+
}
|
|
542
|
+
else if (this.networkPolicy) {
|
|
576
543
|
const networkMode = await this.networkPolicy.prepareForContainer(profile.tenantId);
|
|
577
544
|
hostConfig.NetworkMode = networkMode;
|
|
578
545
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { BotNotFoundError, FleetManager
|
|
2
|
+
import { BotNotFoundError, FleetManager } from "./fleet-manager.js";
|
|
3
3
|
// --- Mock helpers ---
|
|
4
4
|
function mockContainer(overrides = {}) {
|
|
5
5
|
return {
|
|
@@ -10,6 +10,7 @@ function mockContainer(overrides = {}) {
|
|
|
10
10
|
remove: vi.fn().mockResolvedValue(undefined),
|
|
11
11
|
inspect: vi.fn().mockResolvedValue({
|
|
12
12
|
Id: "container-123",
|
|
13
|
+
Name: "/wopr-test-bot",
|
|
13
14
|
Created: "2026-01-01T00:00:00Z",
|
|
14
15
|
State: {
|
|
15
16
|
Status: "running",
|
|
@@ -17,6 +18,7 @@ function mockContainer(overrides = {}) {
|
|
|
17
18
|
StartedAt: "2026-01-01T00:00:00Z",
|
|
18
19
|
Health: { Status: "healthy" },
|
|
19
20
|
},
|
|
21
|
+
NetworkSettings: { Ports: {} },
|
|
20
22
|
}),
|
|
21
23
|
stats: vi.fn().mockResolvedValue({
|
|
22
24
|
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
|
@@ -89,9 +91,9 @@ describe("FleetManager", () => {
|
|
|
89
91
|
});
|
|
90
92
|
describe("create", () => {
|
|
91
93
|
it("saves profile, pulls image, and creates container", async () => {
|
|
92
|
-
const
|
|
93
|
-
expect(
|
|
94
|
-
expect(profile.name).toBe("test-bot");
|
|
94
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
95
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
96
|
+
expect(instance.profile.name).toBe("test-bot");
|
|
95
97
|
expect(store.save).toHaveBeenCalledWith(expect.objectContaining({ name: "test-bot" }));
|
|
96
98
|
expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable", {});
|
|
97
99
|
expect(docker.createContainer).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -111,79 +113,18 @@ describe("FleetManager", () => {
|
|
|
111
113
|
});
|
|
112
114
|
});
|
|
113
115
|
describe("start", () => {
|
|
114
|
-
it("starts
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Id: "container-123",
|
|
118
|
-
Created: "2026-01-01T00:00:00Z",
|
|
119
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
|
|
120
|
-
});
|
|
121
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
122
|
-
await fleet.start("bot-id");
|
|
123
|
-
expect(container.start).toHaveBeenCalled();
|
|
124
|
-
});
|
|
125
|
-
it("starts an exited container", async () => {
|
|
126
|
-
container.inspect.mockResolvedValue({
|
|
127
|
-
Id: "container-123",
|
|
128
|
-
Created: "2026-01-01T00:00:00Z",
|
|
129
|
-
State: { Status: "exited", Running: false, StartedAt: "", Health: null },
|
|
130
|
-
});
|
|
131
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
132
|
-
await fleet.start("bot-id");
|
|
116
|
+
it("starts a created container via Instance.start()", async () => {
|
|
117
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
118
|
+
await instance.start();
|
|
133
119
|
expect(container.start).toHaveBeenCalled();
|
|
134
120
|
});
|
|
135
|
-
it("starts a dead container", async () => {
|
|
136
|
-
container.inspect.mockResolvedValue({
|
|
137
|
-
Id: "container-123",
|
|
138
|
-
Created: "2026-01-01T00:00:00Z",
|
|
139
|
-
State: { Status: "dead", Running: false, StartedAt: "", Health: null },
|
|
140
|
-
});
|
|
141
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
142
|
-
await fleet.start("bot-id");
|
|
143
|
-
expect(container.start).toHaveBeenCalled();
|
|
144
|
-
});
|
|
145
|
-
it("throws InvalidStateTransitionError with 'unknown' when Status is missing/null", async () => {
|
|
146
|
-
container.inspect.mockResolvedValue({
|
|
147
|
-
Id: "container-123",
|
|
148
|
-
Created: "2026-01-01T00:00:00Z",
|
|
149
|
-
State: { Status: null, Running: false, StartedAt: "", Health: null },
|
|
150
|
-
});
|
|
151
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
152
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
153
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(/unknown/);
|
|
154
|
-
});
|
|
155
|
-
it("throws InvalidStateTransitionError when container is already running", async () => {
|
|
156
|
-
// Default mockContainer has state "running"
|
|
157
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
158
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
159
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(/Cannot start bot/);
|
|
160
|
-
});
|
|
161
|
-
it("throws BotNotFoundError when container not found", async () => {
|
|
162
|
-
docker.listContainers.mockResolvedValue([]);
|
|
163
|
-
await expect(fleet.start("missing")).rejects.toThrow(BotNotFoundError);
|
|
164
|
-
});
|
|
165
121
|
});
|
|
166
122
|
describe("stop", () => {
|
|
167
|
-
it("stops a running container", async () => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await fleet.stop("bot-id");
|
|
123
|
+
it("stops a running container via Instance.stop()", async () => {
|
|
124
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
125
|
+
await instance.stop();
|
|
171
126
|
expect(container.stop).toHaveBeenCalled();
|
|
172
127
|
});
|
|
173
|
-
it("throws InvalidStateTransitionError when container is already stopped", async () => {
|
|
174
|
-
container.inspect.mockResolvedValue({
|
|
175
|
-
Id: "container-123",
|
|
176
|
-
Created: "2026-01-01T00:00:00Z",
|
|
177
|
-
State: { Status: "exited", Running: false, StartedAt: "", Health: null },
|
|
178
|
-
});
|
|
179
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
180
|
-
await expect(fleet.stop("bot-id")).rejects.toThrow(InvalidStateTransitionError);
|
|
181
|
-
await expect(fleet.stop("bot-id")).rejects.toThrow(/Cannot stop bot/);
|
|
182
|
-
});
|
|
183
|
-
it("throws BotNotFoundError when container not found", async () => {
|
|
184
|
-
docker.listContainers.mockResolvedValue([]);
|
|
185
|
-
await expect(fleet.stop("missing")).rejects.toThrow(BotNotFoundError);
|
|
186
|
-
});
|
|
187
128
|
});
|
|
188
129
|
describe("restart", () => {
|
|
189
130
|
it("pulls image before restarting a running container", async () => {
|
|
@@ -406,21 +347,23 @@ describe("FleetManager", () => {
|
|
|
406
347
|
undefined, // networkPolicy
|
|
407
348
|
proxyManager);
|
|
408
349
|
});
|
|
409
|
-
it("calls addRoute
|
|
410
|
-
const
|
|
350
|
+
it("calls addRoute via setupProxy with correct subdomain and upstream", async () => {
|
|
351
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
352
|
+
await instance.setupProxy();
|
|
411
353
|
expect(proxyManager.addRoute).toHaveBeenCalledWith({
|
|
412
|
-
instanceId:
|
|
354
|
+
instanceId: instance.id,
|
|
413
355
|
subdomain: "test-bot",
|
|
414
356
|
upstreamHost: "wopr-test-bot",
|
|
415
357
|
upstreamPort: 7437,
|
|
416
358
|
healthy: true,
|
|
417
359
|
});
|
|
418
360
|
});
|
|
419
|
-
it("still returns
|
|
361
|
+
it("still returns instance when setupProxy addRoute fails (non-fatal)", async () => {
|
|
362
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
420
363
|
proxyManager.addRoute.mockRejectedValueOnce(new Error("DNS fail"));
|
|
421
|
-
|
|
422
|
-
expect(
|
|
423
|
-
expect(profile.name).toBe("test-bot");
|
|
364
|
+
await instance.setupProxy();
|
|
365
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
366
|
+
expect(instance.profile.name).toBe("test-bot");
|
|
424
367
|
});
|
|
425
368
|
it("calls removeRoute on remove", async () => {
|
|
426
369
|
await store.save({ id: "bot-id", ...PROFILE_PARAMS });
|
|
@@ -428,32 +371,29 @@ describe("FleetManager", () => {
|
|
|
428
371
|
await proxyFleet.remove("bot-id");
|
|
429
372
|
expect(proxyManager.removeRoute).toHaveBeenCalledWith("bot-id");
|
|
430
373
|
});
|
|
431
|
-
it("
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
|
|
436
|
-
});
|
|
437
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
438
|
-
await proxyFleet.start("bot-id");
|
|
439
|
-
expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", true);
|
|
374
|
+
it("starts via Instance.start()", async () => {
|
|
375
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
376
|
+
await instance.start();
|
|
377
|
+
expect(container.start).toHaveBeenCalled();
|
|
440
378
|
});
|
|
441
|
-
it("
|
|
442
|
-
|
|
443
|
-
await
|
|
444
|
-
expect(
|
|
379
|
+
it("stops via Instance.stop()", async () => {
|
|
380
|
+
const instance = await proxyFleet.create(PROFILE_PARAMS);
|
|
381
|
+
await instance.stop();
|
|
382
|
+
expect(container.stop).toHaveBeenCalled();
|
|
445
383
|
});
|
|
446
384
|
it("does not call proxy methods when no proxyManager is provided", async () => {
|
|
447
385
|
// `fleet` from the outer beforeEach has no proxyManager
|
|
448
|
-
const
|
|
386
|
+
const instance = await fleet.create(PROFILE_PARAMS);
|
|
387
|
+
await instance.setupProxy(); // no-op since no proxyManager
|
|
449
388
|
expect(proxyManager.addRoute).not.toHaveBeenCalled();
|
|
450
|
-
expect(
|
|
389
|
+
expect(instance.id).toEqual(expect.any(String));
|
|
451
390
|
});
|
|
452
391
|
it("normalizes underscores to hyphens in subdomain", async () => {
|
|
453
|
-
await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
|
|
392
|
+
const instance = await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
|
|
393
|
+
await instance.setupProxy();
|
|
454
394
|
expect(proxyManager.addRoute).toHaveBeenCalledWith(expect.objectContaining({
|
|
455
395
|
subdomain: "my-cool-bot",
|
|
456
|
-
upstreamHost: "wopr-
|
|
396
|
+
upstreamHost: "wopr-test-bot",
|
|
457
397
|
}));
|
|
458
398
|
});
|
|
459
399
|
});
|
|
@@ -503,64 +443,6 @@ describe("FleetManager", () => {
|
|
|
503
443
|
});
|
|
504
444
|
});
|
|
505
445
|
describe("per-bot mutex", () => {
|
|
506
|
-
it("serializes 5 concurrent startBot calls into exactly 1 start per call", async () => {
|
|
507
|
-
// Containers must be in stopped state so the state guard passes
|
|
508
|
-
container.inspect.mockResolvedValue({
|
|
509
|
-
Id: "container-123",
|
|
510
|
-
Created: "2026-01-01T00:00:00Z",
|
|
511
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
|
|
512
|
-
});
|
|
513
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
514
|
-
let startCount = 0;
|
|
515
|
-
container.start.mockImplementation(async () => {
|
|
516
|
-
startCount++;
|
|
517
|
-
const current = startCount;
|
|
518
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
519
|
-
expect(startCount).toBe(current);
|
|
520
|
-
});
|
|
521
|
-
const promises = Array.from({ length: 5 }, () => fleet.start("bot-id"));
|
|
522
|
-
await Promise.all(promises);
|
|
523
|
-
expect(container.start).toHaveBeenCalledTimes(5);
|
|
524
|
-
});
|
|
525
|
-
it("does not block operations on different bots (proves concurrency)", async () => {
|
|
526
|
-
// For this test: start needs stopped state, stop needs running state.
|
|
527
|
-
// Both bot-a and bot-b share the same mock container.
|
|
528
|
-
// Use a stopped state so start() passes, and mock stop() directly so it bypasses state check.
|
|
529
|
-
container.inspect.mockResolvedValue({
|
|
530
|
-
Id: "container-123",
|
|
531
|
-
Created: "2026-01-01T00:00:00Z",
|
|
532
|
-
State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
|
|
533
|
-
});
|
|
534
|
-
docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
|
|
535
|
-
let releaseStart;
|
|
536
|
-
container.start.mockImplementation(() => new Promise((resolve) => {
|
|
537
|
-
releaseStart = resolve;
|
|
538
|
-
}));
|
|
539
|
-
const stopSpy = vi.fn();
|
|
540
|
-
// Override inspect for the stop path to return "running" state
|
|
541
|
-
let callCount = 0;
|
|
542
|
-
container.inspect.mockImplementation(async () => {
|
|
543
|
-
callCount++;
|
|
544
|
-
// First call is for bot-a start, subsequent for bot-b stop
|
|
545
|
-
return callCount === 1
|
|
546
|
-
? { Id: "container-123", State: { Status: "stopped", Running: false } }
|
|
547
|
-
: { Id: "container-123", State: { Status: "running", Running: true } };
|
|
548
|
-
});
|
|
549
|
-
container.stop.mockImplementation(async () => {
|
|
550
|
-
stopSpy();
|
|
551
|
-
});
|
|
552
|
-
const startPromise = fleet.start("bot-a");
|
|
553
|
-
await Promise.resolve(); // allow start lock acquisition
|
|
554
|
-
await expect(fleet.stop("bot-b")).resolves.toBeUndefined();
|
|
555
|
-
expect(stopSpy).toHaveBeenCalledTimes(1);
|
|
556
|
-
releaseStart();
|
|
557
|
-
await startPromise;
|
|
558
|
-
});
|
|
559
|
-
it("releases lock even when operation throws", async () => {
|
|
560
|
-
docker.listContainers.mockResolvedValue([]);
|
|
561
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
|
|
562
|
-
await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
|
|
563
|
-
});
|
|
564
446
|
it("serializes concurrent create calls with the same explicit ID (mutual exclusion)", async () => {
|
|
565
447
|
// Add a delay to store.save so the race is observable: the mutex must ensure
|
|
566
448
|
// the first save completes before the second call checks for the existing profile.
|
|
@@ -628,7 +510,7 @@ describe("FleetManager", () => {
|
|
|
628
510
|
const botId = "bot-remote-1";
|
|
629
511
|
await instanceRepo.create({ id: botId, tenantId: "t1", name: "test-bot", nodeId: "node-1" });
|
|
630
512
|
const fleet = new FleetManager(docker, store, undefined, undefined, undefined, bus, instanceRepo);
|
|
631
|
-
const
|
|
513
|
+
const instance = await fleet.create({
|
|
632
514
|
id: botId,
|
|
633
515
|
tenantId: "t1",
|
|
634
516
|
name: "test-bot",
|
|
@@ -639,7 +521,7 @@ describe("FleetManager", () => {
|
|
|
639
521
|
releaseChannel: "stable",
|
|
640
522
|
updatePolicy: "manual",
|
|
641
523
|
});
|
|
642
|
-
expect(
|
|
524
|
+
expect(instance.id).toBe(botId);
|
|
643
525
|
expect(bus.send).toHaveBeenCalledWith("node-1", {
|
|
644
526
|
type: "bot.start",
|
|
645
527
|
payload: {
|
|
@@ -675,31 +557,6 @@ describe("FleetManager", () => {
|
|
|
675
557
|
expect(bus.send).not.toHaveBeenCalled();
|
|
676
558
|
expect(docker.pull).toHaveBeenCalled();
|
|
677
559
|
});
|
|
678
|
-
it("should dispatch stop via commandBus when bot has nodeId", async () => {
|
|
679
|
-
const container = mockContainer();
|
|
680
|
-
const docker = mockDocker(container);
|
|
681
|
-
const store = mockStore();
|
|
682
|
-
const bus = mockCommandBus();
|
|
683
|
-
const instanceRepo = mockInstanceRepo();
|
|
684
|
-
const botId = "bot-stop-1";
|
|
685
|
-
await instanceRepo.create({ id: botId, tenantId: "t1", name: "stop-bot", nodeId: "node-2" });
|
|
686
|
-
const fleet = new FleetManager(docker, store, undefined, undefined, undefined, bus, instanceRepo);
|
|
687
|
-
await store.save({
|
|
688
|
-
id: botId,
|
|
689
|
-
tenantId: "t1",
|
|
690
|
-
name: "stop-bot",
|
|
691
|
-
description: "",
|
|
692
|
-
image: "ghcr.io/wopr-network/wopr:latest",
|
|
693
|
-
env: {},
|
|
694
|
-
restartPolicy: "unless-stopped",
|
|
695
|
-
});
|
|
696
|
-
await fleet.stop(botId);
|
|
697
|
-
expect(bus.send).toHaveBeenCalledWith("node-2", {
|
|
698
|
-
type: "bot.stop",
|
|
699
|
-
payload: { name: "stop-bot" },
|
|
700
|
-
});
|
|
701
|
-
expect(container.stop).not.toHaveBeenCalled();
|
|
702
|
-
});
|
|
703
560
|
it("should dispatch restart via commandBus when bot has nodeId", async () => {
|
|
704
561
|
const container = mockContainer();
|
|
705
562
|
const docker = mockDocker(container);
|