@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
|
@@ -428,36 +428,36 @@ describe("ContainerUpdater", () => {
|
|
|
428
428
|
expect(result.error).toBe("Bot not found");
|
|
429
429
|
});
|
|
430
430
|
it("performs update by pulling, stopping, and recreating", async () => {
|
|
431
|
-
//
|
|
432
|
-
//
|
|
431
|
+
// Trace through doUpdateBot("bot-1"):
|
|
432
|
+
// 1. getContainerDigest: listContainers + getContainer + inspect + getImage
|
|
433
|
+
// 2. wasRunning check: listContainers + getContainer + inspect
|
|
434
|
+
// 3. docker.pull + followProgress
|
|
435
|
+
// 4. fleet.update(botId, { image }):
|
|
436
|
+
// a. findContainer: listContainers + getContainer
|
|
437
|
+
// b. inspect (wasRunning check)
|
|
438
|
+
// c. pullImage (updates.image set): docker.pull + followProgress
|
|
439
|
+
// d. stop + remove
|
|
440
|
+
// e. createContainer
|
|
441
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
442
|
+
// g. start
|
|
443
|
+
// 5. waitForHealthy: listContainers + getContainer + inspect
|
|
444
|
+
// 6. getContainerDigest (final): listContainers + getContainer + inspect + getImage
|
|
433
445
|
docker.listContainers
|
|
434
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
|
|
435
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
436
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer
|
|
437
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer after recreate
|
|
438
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
439
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); //
|
|
446
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
447
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
448
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
449
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
450
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
451
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
|
|
440
452
|
docker.getContainer.mockReturnValue(container);
|
|
441
|
-
// Sequence inspect calls: first two return "running" (for wasRunning checks in
|
|
442
|
-
// doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
|
|
443
|
-
// assertValidState guard allows the start, subsequent calls use the default (running+healthy).
|
|
444
453
|
const runningInspect = {
|
|
445
454
|
Id: "container-123",
|
|
446
455
|
Image: "sha256:abc123",
|
|
447
456
|
Created: "2026-01-01T00:00:00Z",
|
|
448
457
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
449
458
|
};
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
Image: "sha256:abc123",
|
|
453
|
-
Created: "2026-01-01T00:00:00Z",
|
|
454
|
-
State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
455
|
-
};
|
|
456
|
-
container.inspect
|
|
457
|
-
.mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
|
|
458
|
-
.mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
|
|
459
|
-
.mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
|
|
460
|
-
.mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
|
|
459
|
+
// All inspect calls return running+healthy (getContainerDigest, wasRunning checks, waitForHealthy, final digest)
|
|
460
|
+
container.inspect.mockResolvedValue(runningInspect);
|
|
461
461
|
const result = await updater.updateBot("bot-1");
|
|
462
462
|
expect(result.success).toBe(true);
|
|
463
463
|
expect(result.rolledBack).toBe(false);
|
|
@@ -469,34 +469,25 @@ describe("ContainerUpdater", () => {
|
|
|
469
469
|
docker.modem.followProgress.mockImplementationOnce((_stream, cb) => {
|
|
470
470
|
resolvePull = cb;
|
|
471
471
|
});
|
|
472
|
+
// After the first followProgress (which hangs), subsequent calls use default (instant resolve)
|
|
473
|
+
// Trace: getContainerDigest, wasRunning, fleet.update->findContainer,
|
|
474
|
+
// fleet.update->findContainer after recreate, waitForHealthy, final getContainerDigest
|
|
472
475
|
docker.listContainers
|
|
473
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
474
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
475
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update
|
|
476
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.
|
|
477
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
|
|
478
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); //
|
|
476
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
477
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
478
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
479
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
480
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
481
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
|
|
479
482
|
docker.getContainer.mockReturnValue(container);
|
|
480
|
-
// Sequence inspect calls: first two return "running" (for wasRunning checks in
|
|
481
|
-
// doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
|
|
482
|
-
// assertValidState guard allows the start.
|
|
483
483
|
const runningInspect = {
|
|
484
484
|
Id: "container-123",
|
|
485
485
|
Image: "sha256:abc123",
|
|
486
486
|
Created: "2026-01-01T00:00:00Z",
|
|
487
487
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
488
488
|
};
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
Image: "sha256:abc123",
|
|
492
|
-
Created: "2026-01-01T00:00:00Z",
|
|
493
|
-
State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
494
|
-
};
|
|
495
|
-
container.inspect
|
|
496
|
-
.mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
|
|
497
|
-
.mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
|
|
498
|
-
.mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
|
|
499
|
-
.mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
|
|
489
|
+
// All inspect calls return running+healthy
|
|
490
|
+
container.inspect.mockResolvedValue(runningInspect);
|
|
500
491
|
// Start first update (will block on pull)
|
|
501
492
|
const first = updater.updateBot("bot-1");
|
|
502
493
|
// Wait for the pull to be called so the lock is held
|
|
@@ -517,6 +508,7 @@ describe("ContainerUpdater", () => {
|
|
|
517
508
|
const unhealthyContainer = mockContainer({
|
|
518
509
|
inspect: vi.fn().mockResolvedValue({
|
|
519
510
|
Id: "container-123",
|
|
511
|
+
Name: "/wopr-test-bot",
|
|
520
512
|
Image: "sha256:new999",
|
|
521
513
|
Created: "2026-01-01T00:00:00Z",
|
|
522
514
|
State: {
|
|
@@ -525,23 +517,46 @@ describe("ContainerUpdater", () => {
|
|
|
525
517
|
StartedAt: "2026-01-01T00:00:00Z",
|
|
526
518
|
Health: { Status: "unhealthy" },
|
|
527
519
|
},
|
|
520
|
+
NetworkSettings: { Ports: {} },
|
|
528
521
|
}),
|
|
529
522
|
});
|
|
530
|
-
//
|
|
523
|
+
// Trace through doUpdateBot("bot-1"):
|
|
524
|
+
// 1. getContainerDigest: listContainers + getContainer + inspect + getImage
|
|
525
|
+
// 2. wasRunning check: listContainers + getContainer + inspect
|
|
526
|
+
// 3. docker.pull + followProgress
|
|
527
|
+
// 4. fleet.update(botId, { image }):
|
|
528
|
+
// a. findContainer: listContainers + getContainer
|
|
529
|
+
// b. inspect (wasRunning check in update)
|
|
530
|
+
// c. pullImage (updates.image is set): docker.pull + followProgress
|
|
531
|
+
// d. stop + remove
|
|
532
|
+
// e. createContainer
|
|
533
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
534
|
+
// g. start
|
|
535
|
+
// 5. waitForHealthy: listContainers + getContainer + inspect
|
|
536
|
+
// 6. unhealthy -> rollback via fleet.update:
|
|
537
|
+
// a. findContainer: listContainers + getContainer
|
|
538
|
+
// b. inspect (wasRunning)
|
|
539
|
+
// c. pullImage: docker.pull + followProgress
|
|
540
|
+
// d. stop + remove
|
|
541
|
+
// e. createContainer
|
|
542
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
543
|
+
// g. start
|
|
531
544
|
docker.listContainers
|
|
532
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
|
|
533
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
534
|
-
.mockResolvedValueOnce([]) // fleet.update -> findContainer
|
|
535
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.
|
|
536
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
|
|
537
|
-
.mockResolvedValueOnce([]) // rollback fleet.update -> findContainer
|
|
538
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); // rollback fleet.
|
|
545
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
546
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
547
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
548
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
549
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
550
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 6a: rollback fleet.update -> findContainer
|
|
551
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6f: rollback fleet.update -> findContainer after recreate
|
|
539
552
|
docker.getContainer
|
|
540
|
-
.mockReturnValueOnce(container) // getContainerDigest
|
|
541
|
-
.mockReturnValueOnce(container) //
|
|
542
|
-
.mockReturnValueOnce(container) // fleet.
|
|
543
|
-
.mockReturnValueOnce(
|
|
544
|
-
.mockReturnValueOnce(
|
|
553
|
+
.mockReturnValueOnce(container) // 1: getContainerDigest
|
|
554
|
+
.mockReturnValueOnce(container) // 2: doUpdateBot wasRunning
|
|
555
|
+
.mockReturnValueOnce(container) // 4a: fleet.update -> findContainer (stop+remove this)
|
|
556
|
+
.mockReturnValueOnce(container) // 4f: fleet.update -> findContainer after recreate (start)
|
|
557
|
+
.mockReturnValueOnce(unhealthyContainer) // 5: waitForHealthy -> unhealthy
|
|
558
|
+
.mockReturnValueOnce(container) // 6a: rollback fleet.update -> findContainer (stop+remove)
|
|
559
|
+
.mockReturnValueOnce(container); // 6f: rollback fleet.update -> findContainer after recreate (start)
|
|
545
560
|
const result = await updater.updateBot("bot-1");
|
|
546
561
|
expect(result.success).toBe(false);
|
|
547
562
|
expect(result.rolledBack).toBe(true);
|
package/dist/fleet/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./fleet-manager.js";
|
|
|
3
3
|
export type { FleetNotificationListenerDeps } from "./fleet-notification-listener.js";
|
|
4
4
|
export { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
5
5
|
export * from "./init-fleet-updater.js";
|
|
6
|
+
export * from "./instance.js";
|
|
6
7
|
export * from "./repository-types.js";
|
|
7
8
|
export * from "./rollout-orchestrator.js";
|
|
8
9
|
export * from "./rollout-strategy.js";
|
package/dist/fleet/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./drizzle-tenant-update-config-repository.js";
|
|
|
2
2
|
export * from "./fleet-manager.js";
|
|
3
3
|
export { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
4
4
|
export * from "./init-fleet-updater.js";
|
|
5
|
+
export * from "./instance.js";
|
|
5
6
|
export * from "./repository-types.js";
|
|
6
7
|
export * from "./rollout-orchestrator.js";
|
|
7
8
|
export * from "./rollout-strategy.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
3
|
+
import type { IBotInstanceRepository } from "./bot-instance-repository.js";
|
|
4
|
+
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
5
|
+
import type { BotProfile } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Instance — a runtime handle to a container.
|
|
8
|
+
*
|
|
9
|
+
* FleetManager is the factory: pull image, create container, return Instance.
|
|
10
|
+
* Instance owns its lifecycle: start, stop, remove, setupBilling, setupProxy.
|
|
11
|
+
*
|
|
12
|
+
* Ephemeral instances (e.g., holyshippers) skip billing and proxy setup.
|
|
13
|
+
* They bill per-token at the gateway layer, not per-instance.
|
|
14
|
+
*/
|
|
15
|
+
export interface InstanceDeps {
|
|
16
|
+
docker: Docker;
|
|
17
|
+
profile: BotProfile;
|
|
18
|
+
containerId: string;
|
|
19
|
+
containerName: string;
|
|
20
|
+
url: string;
|
|
21
|
+
/** Optional — non-ephemeral instances use these for billing/proxy/events */
|
|
22
|
+
instanceRepo?: IBotInstanceRepository;
|
|
23
|
+
proxyManager?: ProxyManagerInterface;
|
|
24
|
+
eventEmitter?: FleetEventEmitter;
|
|
25
|
+
}
|
|
26
|
+
export declare class Instance {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly containerId: string;
|
|
29
|
+
readonly containerName: string;
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly profile: BotProfile;
|
|
32
|
+
private readonly docker;
|
|
33
|
+
private readonly instanceRepo;
|
|
34
|
+
private readonly proxyManager;
|
|
35
|
+
private readonly eventEmitter;
|
|
36
|
+
constructor(deps: InstanceDeps);
|
|
37
|
+
/** Emit bot.created — call only from FleetManager.create(), not getInstance() */
|
|
38
|
+
emitCreated(): void;
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
stop(): Promise<void>;
|
|
41
|
+
remove(): Promise<void>;
|
|
42
|
+
status(): Promise<"running" | "stopped" | "gone">;
|
|
43
|
+
/**
|
|
44
|
+
* Register this instance in the billing system.
|
|
45
|
+
* Skip for ephemeral instances — they bill per-token, not per-instance.
|
|
46
|
+
*/
|
|
47
|
+
setupBilling(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Register a proxy route for tenant subdomain routing.
|
|
50
|
+
* Skip for ephemeral instances — they're accessed directly via Docker DNS.
|
|
51
|
+
*/
|
|
52
|
+
setupProxy(): Promise<void>;
|
|
53
|
+
private emit;
|
|
54
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { logger } from "../config/logger.js";
|
|
2
|
+
export class Instance {
|
|
3
|
+
id;
|
|
4
|
+
containerId;
|
|
5
|
+
containerName;
|
|
6
|
+
url;
|
|
7
|
+
profile;
|
|
8
|
+
docker;
|
|
9
|
+
instanceRepo;
|
|
10
|
+
proxyManager;
|
|
11
|
+
eventEmitter;
|
|
12
|
+
constructor(deps) {
|
|
13
|
+
this.id = deps.profile.id;
|
|
14
|
+
this.containerId = deps.containerId;
|
|
15
|
+
this.containerName = deps.containerName;
|
|
16
|
+
this.url = deps.url;
|
|
17
|
+
this.profile = deps.profile;
|
|
18
|
+
this.docker = deps.docker;
|
|
19
|
+
this.instanceRepo = deps.instanceRepo;
|
|
20
|
+
this.proxyManager = deps.proxyManager;
|
|
21
|
+
this.eventEmitter = deps.eventEmitter;
|
|
22
|
+
}
|
|
23
|
+
/** Emit bot.created — call only from FleetManager.create(), not getInstance() */
|
|
24
|
+
emitCreated() {
|
|
25
|
+
this.emit("bot.created");
|
|
26
|
+
}
|
|
27
|
+
async start() {
|
|
28
|
+
const container = this.docker.getContainer(this.containerId);
|
|
29
|
+
await container.start();
|
|
30
|
+
logger.info(`Instance started`, { id: this.id, containerName: this.containerName, url: this.url });
|
|
31
|
+
this.emit("bot.started");
|
|
32
|
+
}
|
|
33
|
+
async stop() {
|
|
34
|
+
const container = this.docker.getContainer(this.containerId);
|
|
35
|
+
try {
|
|
36
|
+
await container.stop({ t: 10 });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
40
|
+
if (!msg.includes("not running") && !msg.includes("already stopped")) {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
logger.info(`Instance stopped`, { id: this.id, containerName: this.containerName });
|
|
45
|
+
this.emit("bot.stopped");
|
|
46
|
+
}
|
|
47
|
+
async remove() {
|
|
48
|
+
const container = this.docker.getContainer(this.containerId);
|
|
49
|
+
try {
|
|
50
|
+
await container.stop({ t: 5 }).catch(() => { });
|
|
51
|
+
await container.remove({ force: true });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
if (!msg.includes("No such container")) {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (this.proxyManager) {
|
|
60
|
+
try {
|
|
61
|
+
await this.proxyManager.removeRoute(this.id);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
logger.warn("Proxy route cleanup failed (non-fatal)", { id: this.id, err });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
logger.info(`Instance removed`, { id: this.id, containerName: this.containerName });
|
|
68
|
+
this.emit("bot.removed");
|
|
69
|
+
}
|
|
70
|
+
async status() {
|
|
71
|
+
try {
|
|
72
|
+
const container = this.docker.getContainer(this.containerId);
|
|
73
|
+
const info = await container.inspect();
|
|
74
|
+
return info.State.Running ? "running" : "stopped";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "gone";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Register this instance in the billing system.
|
|
82
|
+
* Skip for ephemeral instances — they bill per-token, not per-instance.
|
|
83
|
+
*/
|
|
84
|
+
async setupBilling() {
|
|
85
|
+
if (this.profile.ephemeral) {
|
|
86
|
+
logger.info("Skipping billing setup (ephemeral)", { id: this.id });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!this.instanceRepo) {
|
|
90
|
+
logger.warn("No instance repo — billing setup skipped", { id: this.id });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await this.instanceRepo.register(this.id, this.profile.tenantId, this.profile.name);
|
|
94
|
+
logger.info("Billing registered", { id: this.id, tenantId: this.profile.tenantId });
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Register a proxy route for tenant subdomain routing.
|
|
98
|
+
* Skip for ephemeral instances — they're accessed directly via Docker DNS.
|
|
99
|
+
*/
|
|
100
|
+
async setupProxy() {
|
|
101
|
+
if (this.profile.ephemeral) {
|
|
102
|
+
logger.info("Skipping proxy setup (ephemeral)", { id: this.id });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!this.proxyManager) {
|
|
106
|
+
logger.warn("No proxy manager — proxy setup skipped", { id: this.id });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const subdomain = this.profile.name.toLowerCase().replace(/_/g, "-");
|
|
111
|
+
const envPort = this.profile.env?.PORT;
|
|
112
|
+
const upstreamPort = envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
|
|
113
|
+
await this.proxyManager.addRoute({
|
|
114
|
+
instanceId: this.id,
|
|
115
|
+
subdomain,
|
|
116
|
+
upstreamHost: this.containerName,
|
|
117
|
+
upstreamPort,
|
|
118
|
+
healthy: true,
|
|
119
|
+
});
|
|
120
|
+
logger.info("Proxy route registered", { id: this.id, subdomain });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
logger.warn("Proxy route registration failed (non-fatal)", { id: this.id, err });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
emit(type) {
|
|
127
|
+
if (this.eventEmitter) {
|
|
128
|
+
this.eventEmitter.emit({
|
|
129
|
+
type,
|
|
130
|
+
botId: this.id,
|
|
131
|
+
tenantId: this.profile.tenantId,
|
|
132
|
+
timestamp: new Date().toISOString(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/fleet/updater.js
CHANGED
|
@@ -100,15 +100,11 @@ export class ContainerUpdater {
|
|
|
100
100
|
resolve();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
-
// Step 2: Delegate stop -> remove -> recreate to FleetManager.update().
|
|
104
|
-
// fleet.update() already implements: stop old -> remove old -> create new,
|
|
103
|
+
// Step 2: Delegate stop -> remove -> recreate -> start to FleetManager.update().
|
|
104
|
+
// fleet.update() already implements: stop old -> remove old -> create new -> start if was running,
|
|
105
105
|
// with profile rollback if container creation fails.
|
|
106
106
|
await this.fleet.update(botId, { image: profile.image });
|
|
107
|
-
// Step 3:
|
|
108
|
-
if (wasRunning) {
|
|
109
|
-
await this.fleet.start(botId);
|
|
110
|
-
}
|
|
111
|
-
// Step 4: Verify health (only meaningful if container is running)
|
|
107
|
+
// Step 3: Verify health (only meaningful if container is running)
|
|
112
108
|
if (wasRunning) {
|
|
113
109
|
const healthy = await this.waitForHealthy(botId);
|
|
114
110
|
if (!healthy) {
|
|
@@ -196,19 +192,14 @@ export class ContainerUpdater {
|
|
|
196
192
|
* Roll back to a previous image by updating the bot profile and recreating.
|
|
197
193
|
* Only starts the container on rollback if it was running before the update.
|
|
198
194
|
*/
|
|
199
|
-
async rollback(botId, previousImage, previousDigest,
|
|
195
|
+
async rollback(botId, previousImage, previousDigest, _wasRunning) {
|
|
200
196
|
// Use digest-pinned image reference when available to prevent rollback
|
|
201
197
|
// from pulling a newer image that was pushed between the update and rollback.
|
|
202
198
|
const rollbackImage = previousDigest ? `${previousImage.split(":")[0]}@${previousDigest}` : previousImage;
|
|
203
199
|
logger.info(`Rolling back bot ${botId} to ${rollbackImage}`);
|
|
204
200
|
try {
|
|
201
|
+
// fleet.update() handles stop -> remove -> recreate -> start-if-was-running internally
|
|
205
202
|
await this.fleet.update(botId, { image: rollbackImage });
|
|
206
|
-
// Only start on rollback if the container was running before the update
|
|
207
|
-
if (wasRunning) {
|
|
208
|
-
await this.fleet.start(botId).catch((err) => {
|
|
209
|
-
logger.warn(`Failed to start bot ${botId} during rollback (may already be running)`, { err });
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
203
|
return {
|
|
213
204
|
botId,
|
|
214
205
|
success: false,
|
|
@@ -66,7 +66,6 @@ function mockDocker(container = null) {
|
|
|
66
66
|
function mockFleet() {
|
|
67
67
|
return {
|
|
68
68
|
update: vi.fn().mockResolvedValue(undefined),
|
|
69
|
-
start: vi.fn().mockResolvedValue(undefined),
|
|
70
69
|
};
|
|
71
70
|
}
|
|
72
71
|
function mockPoller() {
|
|
@@ -131,7 +130,6 @@ describe("ContainerUpdater", () => {
|
|
|
131
130
|
// Verify the update pipeline was called
|
|
132
131
|
expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable");
|
|
133
132
|
expect(fleet.update).toHaveBeenCalledWith("bot-1", { image: "ghcr.io/wopr-network/wopr:stable" });
|
|
134
|
-
expect(fleet.start).toHaveBeenCalledWith("bot-1");
|
|
135
133
|
});
|
|
136
134
|
it("skips start and health check when container was not running", async () => {
|
|
137
135
|
const stoppedContainer = mockContainer({
|
|
@@ -154,7 +152,6 @@ describe("ContainerUpdater", () => {
|
|
|
154
152
|
const result = await updater.updateBot("bot-1");
|
|
155
153
|
expect(result.success).toBe(true);
|
|
156
154
|
expect(fleet.update).toHaveBeenCalled();
|
|
157
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
158
155
|
});
|
|
159
156
|
it("considers container healthy when no HEALTHCHECK is configured", async () => {
|
|
160
157
|
const noHealthContainer = mockContainer({
|
|
@@ -221,8 +218,6 @@ describe("ContainerUpdater", () => {
|
|
|
221
218
|
expect(fleet.update).toHaveBeenLastCalledWith("bot-1", {
|
|
222
219
|
image: "ghcr.io/wopr-network/wopr@sha256:abc123",
|
|
223
220
|
});
|
|
224
|
-
// Rollback starts the container since it was running
|
|
225
|
-
expect(fleet.start).toHaveBeenCalledTimes(2);
|
|
226
221
|
});
|
|
227
222
|
it("rolls back when health check times out (stays in starting)", async () => {
|
|
228
223
|
const startingContainer = mockContainer({
|
|
@@ -257,16 +252,18 @@ describe("ContainerUpdater", () => {
|
|
|
257
252
|
});
|
|
258
253
|
});
|
|
259
254
|
// --- Rollback on startup failure ---
|
|
260
|
-
describe("rollback on
|
|
261
|
-
it("rolls back when fleet.start
|
|
255
|
+
describe("rollback on update failure", () => {
|
|
256
|
+
it("rolls back when fleet.update throws (start embedded in update)", async () => {
|
|
262
257
|
// getContainerDigest
|
|
263
258
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
264
259
|
docker.getContainer.mockReturnValueOnce(container);
|
|
265
260
|
// wasRunning check
|
|
266
261
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
267
262
|
docker.getContainer.mockReturnValueOnce(container);
|
|
268
|
-
// fleet.
|
|
269
|
-
fleet.
|
|
263
|
+
// fleet.update fails (which now includes starting the container)
|
|
264
|
+
fleet.update
|
|
265
|
+
.mockRejectedValueOnce(new Error("Container start failed"))
|
|
266
|
+
.mockResolvedValueOnce(undefined); // rollback update succeeds
|
|
270
267
|
const result = await updater.updateBot("bot-1");
|
|
271
268
|
expect(result.success).toBe(false);
|
|
272
269
|
expect(result.rolledBack).toBe(true);
|
|
@@ -282,12 +279,10 @@ describe("ContainerUpdater", () => {
|
|
|
282
279
|
// wasRunning check
|
|
283
280
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
284
281
|
docker.getContainer.mockReturnValueOnce(container);
|
|
285
|
-
// fleet.
|
|
286
|
-
fleet.start.mockRejectedValueOnce(new Error("Container start failed"));
|
|
287
|
-
// fleet.update (rollback) also fails
|
|
282
|
+
// fleet.update fails, then rollback fleet.update also fails
|
|
288
283
|
fleet.update
|
|
289
|
-
.
|
|
290
|
-
.mockRejectedValueOnce(new Error("Rollback recreate failed"));
|
|
284
|
+
.mockRejectedValueOnce(new Error("Container start failed"))
|
|
285
|
+
.mockRejectedValueOnce(new Error("Rollback recreate failed"));
|
|
291
286
|
const result = await updater.updateBot("bot-1");
|
|
292
287
|
expect(result.success).toBe(false);
|
|
293
288
|
expect(result.rolledBack).toBe(false);
|
|
@@ -548,8 +543,6 @@ describe("ContainerUpdater", () => {
|
|
|
548
543
|
const result = await updater.updateBot("bot-1");
|
|
549
544
|
expect(result.success).toBe(false);
|
|
550
545
|
expect(result.rolledBack).toBe(true);
|
|
551
|
-
// fleet.start should NOT be called at all — container was stopped
|
|
552
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
553
546
|
});
|
|
554
547
|
});
|
|
555
548
|
// --- Edge case: wasRunning detection failure ---
|
|
@@ -565,8 +558,6 @@ describe("ContainerUpdater", () => {
|
|
|
565
558
|
docker.getContainer.mockReturnValueOnce(container);
|
|
566
559
|
const result = await updater.updateBot("bot-1");
|
|
567
560
|
expect(result.success).toBe(true);
|
|
568
|
-
// Since wasRunning defaults to false on error, start and health check are skipped
|
|
569
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
570
561
|
});
|
|
571
562
|
});
|
|
572
563
|
});
|
|
@@ -46,9 +46,9 @@ export declare const commandSchema: z.ZodObject<{
|
|
|
46
46
|
type: z.ZodEnum<{
|
|
47
47
|
"bot.logs": "bot.logs";
|
|
48
48
|
"bot.start": "bot.start";
|
|
49
|
-
"bot.stop": "bot.stop";
|
|
50
49
|
"bot.restart": "bot.restart";
|
|
51
50
|
"bot.remove": "bot.remove";
|
|
51
|
+
"bot.stop": "bot.stop";
|
|
52
52
|
"bot.update": "bot.update";
|
|
53
53
|
"bot.export": "bot.export";
|
|
54
54
|
"bot.import": "bot.import";
|
package/package.json
CHANGED
|
@@ -12,8 +12,10 @@ function mockContainer() {
|
|
|
12
12
|
remove: vi.fn().mockResolvedValue(undefined),
|
|
13
13
|
inspect: vi.fn().mockResolvedValue({
|
|
14
14
|
Id: "container-123",
|
|
15
|
+
Name: "/wopr-test",
|
|
15
16
|
Created: "2026-01-01T00:00:00Z",
|
|
16
17
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z" },
|
|
18
|
+
NetworkSettings: { Ports: {} },
|
|
17
19
|
}),
|
|
18
20
|
stats: vi.fn().mockResolvedValue({
|
|
19
21
|
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
|
|
@@ -28,7 +30,7 @@ function makeDocker(): Docker {
|
|
|
28
30
|
return {
|
|
29
31
|
pull: vi.fn().mockResolvedValue("stream"),
|
|
30
32
|
createContainer: vi.fn().mockResolvedValue(mockContainer()),
|
|
31
|
-
listContainers: vi.fn().mockResolvedValue([]),
|
|
33
|
+
listContainers: vi.fn().mockResolvedValue([{ Id: "container-123" }]),
|
|
32
34
|
getContainer: vi.fn().mockReturnValue(mockContainer()),
|
|
33
35
|
modem: {
|
|
34
36
|
followProgress: vi.fn((_stream: unknown, cb: (err: Error | null) => void) => cb(null)),
|