@wopr-network/platform-core 1.38.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 -16
- package/dist/fleet/fleet-manager.js +61 -109
- 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/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 +62 -106
- 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/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,26 +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
|
-
*
|
|
46
|
+
* Build an Instance from a profile after container creation.
|
|
47
|
+
* Inspects the Docker container to resolve container name and URL.
|
|
47
48
|
*/
|
|
48
|
-
|
|
49
|
-
id?: string;
|
|
50
|
-
}, resourceLimits?: ContainerResourceLimits): Promise<BotProfile>;
|
|
51
|
-
/**
|
|
52
|
-
* Start a stopped bot container.
|
|
53
|
-
* Valid from: stopped, created, exited, dead, error states.
|
|
54
|
-
* Throws InvalidStateTransitionError if the container is already running.
|
|
55
|
-
*/
|
|
56
|
-
start(id: string): Promise<void>;
|
|
49
|
+
private resolvePort;
|
|
57
50
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* 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.
|
|
61
53
|
*/
|
|
62
|
-
|
|
54
|
+
getInstance(id: string): Promise<Instance>;
|
|
55
|
+
private buildInstance;
|
|
63
56
|
/**
|
|
64
57
|
* Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
|
|
65
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,120 +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
|
-
*
|
|
132
|
+
* Build an Instance from a profile after container creation.
|
|
133
|
+
* Inspects the Docker container to resolve container name and URL.
|
|
136
134
|
*/
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
return profile;
|
|
135
|
+
resolvePort(profile) {
|
|
136
|
+
const envPort = profile.env?.PORT;
|
|
137
|
+
return envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
|
|
141
138
|
}
|
|
142
139
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* Throws InvalidStateTransitionError if the container is already running.
|
|
140
|
+
* Get an Instance handle for an existing bot by ID.
|
|
141
|
+
* Looks up the profile and inspects the Docker container.
|
|
146
142
|
*/
|
|
147
|
-
async
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const profile = await this.store.get(id);
|
|
153
|
-
if (!profile)
|
|
154
|
-
throw new BotNotFoundError(id);
|
|
155
|
-
await remote.commandBus.send(remote.nodeId, {
|
|
156
|
-
type: "bot.start",
|
|
157
|
-
payload: {
|
|
158
|
-
name: profile.name,
|
|
159
|
-
image: profile.image,
|
|
160
|
-
env: profile.env,
|
|
161
|
-
restart: profile.restartPolicy,
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
const container = await this.findContainer(id);
|
|
167
|
-
if (!container)
|
|
168
|
-
throw new BotNotFoundError(id);
|
|
169
|
-
const info = await container.inspect();
|
|
170
|
-
const validStartStates = new Set(["stopped", "created", "exited", "dead", "error"]);
|
|
171
|
-
this.assertValidState(id, info.State.Status, "start", validStartStates);
|
|
172
|
-
await container.start();
|
|
173
|
-
}
|
|
174
|
-
if (this.proxyManager) {
|
|
175
|
-
this.proxyManager.updateHealth(id, true);
|
|
176
|
-
}
|
|
177
|
-
logger.info(`Started bot ${id}`);
|
|
178
|
-
let startedTenantId;
|
|
179
|
-
try {
|
|
180
|
-
startedTenantId = (await this.store.get(id))?.tenantId;
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
logger.warn(`Failed to fetch profile after starting bot ${id}`, { err });
|
|
184
|
-
}
|
|
185
|
-
this.emitEvent("bot.started", id, startedTenantId);
|
|
186
|
-
});
|
|
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);
|
|
187
148
|
}
|
|
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
|
-
this.proxyManager.updateHealth(id, false);
|
|
216
|
-
}
|
|
217
|
-
logger.info(`Stopped bot ${id}`);
|
|
218
|
-
let stoppedTenantId;
|
|
219
|
-
try {
|
|
220
|
-
stoppedTenantId = (await this.store.get(id))?.tenantId;
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
logger.warn(`Failed to fetch profile after stopping bot ${id}`, { err });
|
|
224
|
-
}
|
|
225
|
-
this.emitEvent("bot.stopped", id, stoppedTenantId);
|
|
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,
|
|
226
176
|
});
|
|
227
177
|
}
|
|
228
178
|
/**
|
|
@@ -406,6 +356,8 @@ export class FleetManager {
|
|
|
406
356
|
"volumeName",
|
|
407
357
|
"name",
|
|
408
358
|
"discovery",
|
|
359
|
+
"network",
|
|
360
|
+
"ephemeral",
|
|
409
361
|
]);
|
|
410
362
|
/**
|
|
411
363
|
* Update a bot profile. Only recreates the container if container-relevant
|
|
@@ -572,10 +524,10 @@ export class FleetManager {
|
|
|
572
524
|
Name: restartPolicyMap[profile.restartPolicy] || "",
|
|
573
525
|
},
|
|
574
526
|
Binds: binds.length > 0 ? binds : undefined,
|
|
575
|
-
SecurityOpt:
|
|
527
|
+
SecurityOpt: ["no-new-privileges"],
|
|
576
528
|
CapDrop: isEphemeral ? undefined : ["ALL"],
|
|
577
529
|
CapAdd: isEphemeral ? undefined : ["NET_BIND_SERVICE"],
|
|
578
|
-
ReadonlyRootfs: isEphemeral
|
|
530
|
+
ReadonlyRootfs: !isEphemeral,
|
|
579
531
|
Tmpfs: isEphemeral
|
|
580
532
|
? undefined
|
|
581
533
|
: {
|
|
@@ -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);
|