@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
|
@@ -530,38 +530,38 @@ describe("ContainerUpdater", () => {
|
|
|
530
530
|
});
|
|
531
531
|
|
|
532
532
|
it("performs update by pulling, stopping, and recreating", async () => {
|
|
533
|
-
//
|
|
534
|
-
//
|
|
533
|
+
// Trace through doUpdateBot("bot-1"):
|
|
534
|
+
// 1. getContainerDigest: listContainers + getContainer + inspect + getImage
|
|
535
|
+
// 2. wasRunning check: listContainers + getContainer + inspect
|
|
536
|
+
// 3. docker.pull + followProgress
|
|
537
|
+
// 4. fleet.update(botId, { image }):
|
|
538
|
+
// a. findContainer: listContainers + getContainer
|
|
539
|
+
// b. inspect (wasRunning check)
|
|
540
|
+
// c. pullImage (updates.image set): docker.pull + followProgress
|
|
541
|
+
// d. stop + remove
|
|
542
|
+
// e. createContainer
|
|
543
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
544
|
+
// g. start
|
|
545
|
+
// 5. waitForHealthy: listContainers + getContainer + inspect
|
|
546
|
+
// 6. getContainerDigest (final): listContainers + getContainer + inspect + getImage
|
|
535
547
|
docker.listContainers
|
|
536
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
|
|
537
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
538
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer
|
|
539
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer after recreate
|
|
540
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
541
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); //
|
|
548
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
549
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
550
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
551
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
552
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
553
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
|
|
542
554
|
|
|
543
555
|
docker.getContainer.mockReturnValue(container);
|
|
544
556
|
|
|
545
|
-
// Sequence inspect calls: first two return "running" (for wasRunning checks in
|
|
546
|
-
// doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
|
|
547
|
-
// assertValidState guard allows the start, subsequent calls use the default (running+healthy).
|
|
548
557
|
const runningInspect = {
|
|
549
558
|
Id: "container-123",
|
|
550
559
|
Image: "sha256:abc123",
|
|
551
560
|
Created: "2026-01-01T00:00:00Z",
|
|
552
561
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
553
562
|
};
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
Image: "sha256:abc123",
|
|
557
|
-
Created: "2026-01-01T00:00:00Z",
|
|
558
|
-
State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
559
|
-
};
|
|
560
|
-
container.inspect
|
|
561
|
-
.mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
|
|
562
|
-
.mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
|
|
563
|
-
.mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
|
|
564
|
-
.mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
|
|
563
|
+
// All inspect calls return running+healthy (getContainerDigest, wasRunning checks, waitForHealthy, final digest)
|
|
564
|
+
container.inspect.mockResolvedValue(runningInspect);
|
|
565
565
|
|
|
566
566
|
const result = await updater.updateBot("bot-1");
|
|
567
567
|
expect(result.success).toBe(true);
|
|
@@ -576,36 +576,27 @@ describe("ContainerUpdater", () => {
|
|
|
576
576
|
resolvePull = cb;
|
|
577
577
|
});
|
|
578
578
|
|
|
579
|
+
// After the first followProgress (which hangs), subsequent calls use default (instant resolve)
|
|
580
|
+
// Trace: getContainerDigest, wasRunning, fleet.update->findContainer,
|
|
581
|
+
// fleet.update->findContainer after recreate, waitForHealthy, final getContainerDigest
|
|
579
582
|
docker.listContainers
|
|
580
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
581
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
582
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update
|
|
583
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.
|
|
584
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
|
|
585
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); //
|
|
583
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
584
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
585
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
586
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
587
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
588
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
|
|
586
589
|
|
|
587
590
|
docker.getContainer.mockReturnValue(container);
|
|
588
591
|
|
|
589
|
-
// Sequence inspect calls: first two return "running" (for wasRunning checks in
|
|
590
|
-
// doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
|
|
591
|
-
// assertValidState guard allows the start.
|
|
592
592
|
const runningInspect = {
|
|
593
593
|
Id: "container-123",
|
|
594
594
|
Image: "sha256:abc123",
|
|
595
595
|
Created: "2026-01-01T00:00:00Z",
|
|
596
596
|
State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
597
597
|
};
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
Image: "sha256:abc123",
|
|
601
|
-
Created: "2026-01-01T00:00:00Z",
|
|
602
|
-
State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
|
|
603
|
-
};
|
|
604
|
-
container.inspect
|
|
605
|
-
.mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
|
|
606
|
-
.mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
|
|
607
|
-
.mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
|
|
608
|
-
.mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
|
|
598
|
+
// All inspect calls return running+healthy
|
|
599
|
+
container.inspect.mockResolvedValue(runningInspect);
|
|
609
600
|
|
|
610
601
|
// Start first update (will block on pull)
|
|
611
602
|
const first = updater.updateBot("bot-1");
|
|
@@ -631,6 +622,7 @@ describe("ContainerUpdater", () => {
|
|
|
631
622
|
const unhealthyContainer = mockContainer({
|
|
632
623
|
inspect: vi.fn().mockResolvedValue({
|
|
633
624
|
Id: "container-123",
|
|
625
|
+
Name: "/wopr-test-bot",
|
|
634
626
|
Image: "sha256:new999",
|
|
635
627
|
Created: "2026-01-01T00:00:00Z",
|
|
636
628
|
State: {
|
|
@@ -639,25 +631,48 @@ describe("ContainerUpdater", () => {
|
|
|
639
631
|
StartedAt: "2026-01-01T00:00:00Z",
|
|
640
632
|
Health: { Status: "unhealthy" },
|
|
641
633
|
},
|
|
634
|
+
NetworkSettings: { Ports: {} },
|
|
642
635
|
}),
|
|
643
636
|
});
|
|
644
637
|
|
|
645
|
-
//
|
|
638
|
+
// Trace through doUpdateBot("bot-1"):
|
|
639
|
+
// 1. getContainerDigest: listContainers + getContainer + inspect + getImage
|
|
640
|
+
// 2. wasRunning check: listContainers + getContainer + inspect
|
|
641
|
+
// 3. docker.pull + followProgress
|
|
642
|
+
// 4. fleet.update(botId, { image }):
|
|
643
|
+
// a. findContainer: listContainers + getContainer
|
|
644
|
+
// b. inspect (wasRunning check in update)
|
|
645
|
+
// c. pullImage (updates.image is set): docker.pull + followProgress
|
|
646
|
+
// d. stop + remove
|
|
647
|
+
// e. createContainer
|
|
648
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
649
|
+
// g. start
|
|
650
|
+
// 5. waitForHealthy: listContainers + getContainer + inspect
|
|
651
|
+
// 6. unhealthy -> rollback via fleet.update:
|
|
652
|
+
// a. findContainer: listContainers + getContainer
|
|
653
|
+
// b. inspect (wasRunning)
|
|
654
|
+
// c. pullImage: docker.pull + followProgress
|
|
655
|
+
// d. stop + remove
|
|
656
|
+
// e. createContainer
|
|
657
|
+
// f. findContainer after recreate (wasRunning): listContainers + getContainer
|
|
658
|
+
// g. start
|
|
646
659
|
docker.listContainers
|
|
647
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
|
|
648
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) //
|
|
649
|
-
.mockResolvedValueOnce([]) // fleet.update -> findContainer
|
|
650
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.
|
|
651
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
|
|
652
|
-
.mockResolvedValueOnce([]) // rollback fleet.update -> findContainer
|
|
653
|
-
.mockResolvedValueOnce([{ Id: "container-123" }]); // rollback fleet.
|
|
660
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
|
|
661
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
|
|
662
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
|
|
663
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
|
|
664
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
|
|
665
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]) // 6a: rollback fleet.update -> findContainer
|
|
666
|
+
.mockResolvedValueOnce([{ Id: "container-123" }]); // 6f: rollback fleet.update -> findContainer after recreate
|
|
654
667
|
|
|
655
668
|
docker.getContainer
|
|
656
|
-
.mockReturnValueOnce(container) // getContainerDigest
|
|
657
|
-
.mockReturnValueOnce(container) //
|
|
658
|
-
.mockReturnValueOnce(container) // fleet.
|
|
659
|
-
.mockReturnValueOnce(
|
|
660
|
-
.mockReturnValueOnce(
|
|
669
|
+
.mockReturnValueOnce(container) // 1: getContainerDigest
|
|
670
|
+
.mockReturnValueOnce(container) // 2: doUpdateBot wasRunning
|
|
671
|
+
.mockReturnValueOnce(container) // 4a: fleet.update -> findContainer (stop+remove this)
|
|
672
|
+
.mockReturnValueOnce(container) // 4f: fleet.update -> findContainer after recreate (start)
|
|
673
|
+
.mockReturnValueOnce(unhealthyContainer) // 5: waitForHealthy -> unhealthy
|
|
674
|
+
.mockReturnValueOnce(container) // 6a: rollback fleet.update -> findContainer (stop+remove)
|
|
675
|
+
.mockReturnValueOnce(container); // 6f: rollback fleet.update -> findContainer after recreate (start)
|
|
661
676
|
|
|
662
677
|
const result = await updater.updateBot("bot-1");
|
|
663
678
|
expect(result.success).toBe(false);
|
package/src/fleet/index.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";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
import { logger } from "../config/logger.js";
|
|
3
|
+
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
4
|
+
import type { IBotInstanceRepository } from "./bot-instance-repository.js";
|
|
5
|
+
import type { BotEventType, FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
6
|
+
import type { BotProfile } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Instance — a runtime handle to a container.
|
|
10
|
+
*
|
|
11
|
+
* FleetManager is the factory: pull image, create container, return Instance.
|
|
12
|
+
* Instance owns its lifecycle: start, stop, remove, setupBilling, setupProxy.
|
|
13
|
+
*
|
|
14
|
+
* Ephemeral instances (e.g., holyshippers) skip billing and proxy setup.
|
|
15
|
+
* They bill per-token at the gateway layer, not per-instance.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface InstanceDeps {
|
|
19
|
+
docker: Docker;
|
|
20
|
+
profile: BotProfile;
|
|
21
|
+
containerId: string;
|
|
22
|
+
containerName: string;
|
|
23
|
+
url: string;
|
|
24
|
+
/** Optional — non-ephemeral instances use these for billing/proxy/events */
|
|
25
|
+
instanceRepo?: IBotInstanceRepository;
|
|
26
|
+
proxyManager?: ProxyManagerInterface;
|
|
27
|
+
eventEmitter?: FleetEventEmitter;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Instance {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly containerId: string;
|
|
33
|
+
readonly containerName: string;
|
|
34
|
+
readonly url: string;
|
|
35
|
+
readonly profile: BotProfile;
|
|
36
|
+
|
|
37
|
+
private readonly docker: Docker;
|
|
38
|
+
private readonly instanceRepo: IBotInstanceRepository | undefined;
|
|
39
|
+
private readonly proxyManager: ProxyManagerInterface | undefined;
|
|
40
|
+
private readonly eventEmitter: FleetEventEmitter | undefined;
|
|
41
|
+
|
|
42
|
+
constructor(deps: InstanceDeps) {
|
|
43
|
+
this.id = deps.profile.id;
|
|
44
|
+
this.containerId = deps.containerId;
|
|
45
|
+
this.containerName = deps.containerName;
|
|
46
|
+
this.url = deps.url;
|
|
47
|
+
this.profile = deps.profile;
|
|
48
|
+
this.docker = deps.docker;
|
|
49
|
+
this.instanceRepo = deps.instanceRepo;
|
|
50
|
+
this.proxyManager = deps.proxyManager;
|
|
51
|
+
this.eventEmitter = deps.eventEmitter;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Emit bot.created — call only from FleetManager.create(), not getInstance() */
|
|
55
|
+
emitCreated(): void {
|
|
56
|
+
this.emit("bot.created");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async start(): Promise<void> {
|
|
60
|
+
const container = this.docker.getContainer(this.containerId);
|
|
61
|
+
await container.start();
|
|
62
|
+
logger.info(`Instance started`, { id: this.id, containerName: this.containerName, url: this.url });
|
|
63
|
+
this.emit("bot.started");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stop(): Promise<void> {
|
|
67
|
+
const container = this.docker.getContainer(this.containerId);
|
|
68
|
+
try {
|
|
69
|
+
await container.stop({ t: 10 });
|
|
70
|
+
} catch (err: unknown) {
|
|
71
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
72
|
+
if (!msg.includes("not running") && !msg.includes("already stopped")) {
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
logger.info(`Instance stopped`, { id: this.id, containerName: this.containerName });
|
|
77
|
+
this.emit("bot.stopped");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async remove(): Promise<void> {
|
|
81
|
+
const container = this.docker.getContainer(this.containerId);
|
|
82
|
+
try {
|
|
83
|
+
await container.stop({ t: 5 }).catch(() => {});
|
|
84
|
+
await container.remove({ force: true });
|
|
85
|
+
} catch (err: unknown) {
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
if (!msg.includes("No such container")) {
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.proxyManager) {
|
|
93
|
+
try {
|
|
94
|
+
await this.proxyManager.removeRoute(this.id);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger.warn("Proxy route cleanup failed (non-fatal)", { id: this.id, err });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logger.info(`Instance removed`, { id: this.id, containerName: this.containerName });
|
|
101
|
+
this.emit("bot.removed");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async status(): Promise<"running" | "stopped" | "gone"> {
|
|
105
|
+
try {
|
|
106
|
+
const container = this.docker.getContainer(this.containerId);
|
|
107
|
+
const info = await container.inspect();
|
|
108
|
+
return info.State.Running ? "running" : "stopped";
|
|
109
|
+
} catch {
|
|
110
|
+
return "gone";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Register this instance in the billing system.
|
|
116
|
+
* Skip for ephemeral instances — they bill per-token, not per-instance.
|
|
117
|
+
*/
|
|
118
|
+
async setupBilling(): Promise<void> {
|
|
119
|
+
if (this.profile.ephemeral) {
|
|
120
|
+
logger.info("Skipping billing setup (ephemeral)", { id: this.id });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!this.instanceRepo) {
|
|
124
|
+
logger.warn("No instance repo — billing setup skipped", { id: this.id });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await this.instanceRepo.register(this.id, this.profile.tenantId, this.profile.name);
|
|
128
|
+
logger.info("Billing registered", { id: this.id, tenantId: this.profile.tenantId });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Register a proxy route for tenant subdomain routing.
|
|
133
|
+
* Skip for ephemeral instances — they're accessed directly via Docker DNS.
|
|
134
|
+
*/
|
|
135
|
+
async setupProxy(): Promise<void> {
|
|
136
|
+
if (this.profile.ephemeral) {
|
|
137
|
+
logger.info("Skipping proxy setup (ephemeral)", { id: this.id });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!this.proxyManager) {
|
|
141
|
+
logger.warn("No proxy manager — proxy setup skipped", { id: this.id });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const subdomain = this.profile.name.toLowerCase().replace(/_/g, "-");
|
|
146
|
+
const envPort = this.profile.env?.PORT;
|
|
147
|
+
const upstreamPort = envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
|
|
148
|
+
await this.proxyManager.addRoute({
|
|
149
|
+
instanceId: this.id,
|
|
150
|
+
subdomain,
|
|
151
|
+
upstreamHost: this.containerName,
|
|
152
|
+
upstreamPort,
|
|
153
|
+
healthy: true,
|
|
154
|
+
});
|
|
155
|
+
logger.info("Proxy route registered", { id: this.id, subdomain });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logger.warn("Proxy route registration failed (non-fatal)", { id: this.id, err });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private emit(type: BotEventType): void {
|
|
162
|
+
if (this.eventEmitter) {
|
|
163
|
+
this.eventEmitter.emit({
|
|
164
|
+
type,
|
|
165
|
+
botId: this.id,
|
|
166
|
+
tenantId: this.profile.tenantId,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/fleet/types.ts
CHANGED
|
@@ -74,6 +74,10 @@ export const botProfileSchema = z.object({
|
|
|
74
74
|
discovery: discoveryConfigSchema.optional(),
|
|
75
75
|
/** Node this bot was placed on at creation time (optional, for future multi-node routing). */
|
|
76
76
|
nodeId: z.string().uuid().optional(),
|
|
77
|
+
/** Docker network to attach the container to (bypasses NetworkPolicy). */
|
|
78
|
+
network: z.string().min(1).optional(),
|
|
79
|
+
/** When true, disables ReadonlyRootfs and CapDrop for containers that need write access (e.g., ephemeral workers). */
|
|
80
|
+
ephemeral: z.boolean().optional(),
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
export type BotProfile = z.infer<typeof botProfileSchema>;
|
|
@@ -96,6 +100,10 @@ export const createBotSchema = z.object({
|
|
|
96
100
|
discovery: discoveryConfigSchema.optional(),
|
|
97
101
|
/** Node this bot was placed on at creation time (optional, for future multi-node routing). */
|
|
98
102
|
nodeId: z.string().uuid().optional(),
|
|
103
|
+
/** Docker network to attach the container to. */
|
|
104
|
+
network: z.string().min(1).optional(),
|
|
105
|
+
/** When true, disables ReadonlyRootfs and CapDrop for ephemeral workers. */
|
|
106
|
+
ephemeral: z.boolean().optional(),
|
|
99
107
|
});
|
|
100
108
|
|
|
101
109
|
/** Schema for updating a bot via the API */
|
|
@@ -77,7 +77,6 @@ function mockDocker(container: ReturnType<typeof mockContainer> | null = null) {
|
|
|
77
77
|
function mockFleet() {
|
|
78
78
|
return {
|
|
79
79
|
update: vi.fn().mockResolvedValue(undefined),
|
|
80
|
-
start: vi.fn().mockResolvedValue(undefined),
|
|
81
80
|
} as unknown as FleetManager;
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -154,7 +153,6 @@ describe("ContainerUpdater", () => {
|
|
|
154
153
|
// Verify the update pipeline was called
|
|
155
154
|
expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable");
|
|
156
155
|
expect(fleet.update).toHaveBeenCalledWith("bot-1", { image: "ghcr.io/wopr-network/wopr:stable" });
|
|
157
|
-
expect(fleet.start).toHaveBeenCalledWith("bot-1");
|
|
158
156
|
});
|
|
159
157
|
|
|
160
158
|
it("skips start and health check when container was not running", async () => {
|
|
@@ -182,7 +180,6 @@ describe("ContainerUpdater", () => {
|
|
|
182
180
|
|
|
183
181
|
expect(result.success).toBe(true);
|
|
184
182
|
expect(fleet.update).toHaveBeenCalled();
|
|
185
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
186
183
|
});
|
|
187
184
|
|
|
188
185
|
it("considers container healthy when no HEALTHCHECK is configured", async () => {
|
|
@@ -259,8 +256,6 @@ describe("ContainerUpdater", () => {
|
|
|
259
256
|
expect(fleet.update).toHaveBeenLastCalledWith("bot-1", {
|
|
260
257
|
image: "ghcr.io/wopr-network/wopr@sha256:abc123",
|
|
261
258
|
});
|
|
262
|
-
// Rollback starts the container since it was running
|
|
263
|
-
expect(fleet.start).toHaveBeenCalledTimes(2);
|
|
264
259
|
});
|
|
265
260
|
|
|
266
261
|
it("rolls back when health check times out (stays in starting)", async () => {
|
|
@@ -301,8 +296,8 @@ describe("ContainerUpdater", () => {
|
|
|
301
296
|
|
|
302
297
|
// --- Rollback on startup failure ---
|
|
303
298
|
|
|
304
|
-
describe("rollback on
|
|
305
|
-
it("rolls back when fleet.start
|
|
299
|
+
describe("rollback on update failure", () => {
|
|
300
|
+
it("rolls back when fleet.update throws (start embedded in update)", async () => {
|
|
306
301
|
// getContainerDigest
|
|
307
302
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
308
303
|
docker.getContainer.mockReturnValueOnce(container);
|
|
@@ -310,8 +305,10 @@ describe("ContainerUpdater", () => {
|
|
|
310
305
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
311
306
|
docker.getContainer.mockReturnValueOnce(container);
|
|
312
307
|
|
|
313
|
-
// fleet.
|
|
314
|
-
(fleet.
|
|
308
|
+
// fleet.update fails (which now includes starting the container)
|
|
309
|
+
(fleet.update as ReturnType<typeof vi.fn>)
|
|
310
|
+
.mockRejectedValueOnce(new Error("Container start failed"))
|
|
311
|
+
.mockResolvedValueOnce(undefined); // rollback update succeeds
|
|
315
312
|
|
|
316
313
|
const result = await updater.updateBot("bot-1");
|
|
317
314
|
|
|
@@ -331,12 +328,10 @@ describe("ContainerUpdater", () => {
|
|
|
331
328
|
docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
|
|
332
329
|
docker.getContainer.mockReturnValueOnce(container);
|
|
333
330
|
|
|
334
|
-
// fleet.
|
|
335
|
-
(fleet.start as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Container start failed"));
|
|
336
|
-
// fleet.update (rollback) also fails
|
|
331
|
+
// fleet.update fails, then rollback fleet.update also fails
|
|
337
332
|
(fleet.update as ReturnType<typeof vi.fn>)
|
|
338
|
-
.
|
|
339
|
-
.mockRejectedValueOnce(new Error("Rollback recreate failed"));
|
|
333
|
+
.mockRejectedValueOnce(new Error("Container start failed"))
|
|
334
|
+
.mockRejectedValueOnce(new Error("Rollback recreate failed"));
|
|
340
335
|
|
|
341
336
|
const result = await updater.updateBot("bot-1");
|
|
342
337
|
|
|
@@ -666,8 +661,6 @@ describe("ContainerUpdater", () => {
|
|
|
666
661
|
|
|
667
662
|
expect(result.success).toBe(false);
|
|
668
663
|
expect(result.rolledBack).toBe(true);
|
|
669
|
-
// fleet.start should NOT be called at all — container was stopped
|
|
670
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
671
664
|
});
|
|
672
665
|
});
|
|
673
666
|
|
|
@@ -687,8 +680,6 @@ describe("ContainerUpdater", () => {
|
|
|
687
680
|
const result = await updater.updateBot("bot-1");
|
|
688
681
|
|
|
689
682
|
expect(result.success).toBe(true);
|
|
690
|
-
// Since wasRunning defaults to false on error, start and health check are skipped
|
|
691
|
-
expect(fleet.start).not.toHaveBeenCalled();
|
|
692
683
|
});
|
|
693
684
|
});
|
|
694
685
|
});
|
package/src/fleet/updater.ts
CHANGED
|
@@ -122,17 +122,12 @@ export class ContainerUpdater {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
// Step 2: Delegate stop -> remove -> recreate to FleetManager.update().
|
|
126
|
-
// fleet.update() already implements: stop old -> remove old -> create new,
|
|
125
|
+
// Step 2: Delegate stop -> remove -> recreate -> start to FleetManager.update().
|
|
126
|
+
// fleet.update() already implements: stop old -> remove old -> create new -> start if was running,
|
|
127
127
|
// with profile rollback if container creation fails.
|
|
128
128
|
await this.fleet.update(botId, { image: profile.image });
|
|
129
129
|
|
|
130
|
-
// Step 3:
|
|
131
|
-
if (wasRunning) {
|
|
132
|
-
await this.fleet.start(botId);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Step 4: Verify health (only meaningful if container is running)
|
|
130
|
+
// Step 3: Verify health (only meaningful if container is running)
|
|
136
131
|
if (wasRunning) {
|
|
137
132
|
const healthy = await this.waitForHealthy(botId);
|
|
138
133
|
|
|
@@ -229,7 +224,7 @@ export class ContainerUpdater {
|
|
|
229
224
|
botId: string,
|
|
230
225
|
previousImage: string,
|
|
231
226
|
previousDigest: string | null,
|
|
232
|
-
|
|
227
|
+
_wasRunning: boolean,
|
|
233
228
|
): Promise<UpdateResult> {
|
|
234
229
|
// Use digest-pinned image reference when available to prevent rollback
|
|
235
230
|
// from pulling a newer image that was pushed between the update and rollback.
|
|
@@ -237,15 +232,9 @@ export class ContainerUpdater {
|
|
|
237
232
|
logger.info(`Rolling back bot ${botId} to ${rollbackImage}`);
|
|
238
233
|
|
|
239
234
|
try {
|
|
235
|
+
// fleet.update() handles stop -> remove -> recreate -> start-if-was-running internally
|
|
240
236
|
await this.fleet.update(botId, { image: rollbackImage });
|
|
241
237
|
|
|
242
|
-
// Only start on rollback if the container was running before the update
|
|
243
|
-
if (wasRunning) {
|
|
244
|
-
await this.fleet.start(botId).catch((err) => {
|
|
245
|
-
logger.warn(`Failed to start bot ${botId} during rollback (may already be running)`, { err });
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
238
|
return {
|
|
250
239
|
botId,
|
|
251
240
|
success: false,
|