@wopr-network/platform-core 1.24.0 → 1.25.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/__tests__/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/__tests__/init-fleet-updater.test.js +93 -0
- package/dist/fleet/index.d.ts +1 -0
- package/dist/fleet/index.js +1 -0
- package/dist/fleet/init-fleet-updater.d.ts +58 -0
- package/dist/fleet/init-fleet-updater.js +88 -0
- package/package.json +1 -1
- package/src/fleet/__tests__/init-fleet-updater.test.ts +129 -0
- package/src/fleet/index.ts +1 -0
- package/src/fleet/init-fleet-updater.ts +134 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { initFleetUpdater } from "../init-fleet-updater.js";
|
|
3
|
+
function mockDocker() {
|
|
4
|
+
return {};
|
|
5
|
+
}
|
|
6
|
+
function mockFleet() {
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
function mockStore() {
|
|
10
|
+
return {
|
|
11
|
+
list: vi.fn(async () => []),
|
|
12
|
+
get: vi.fn(async () => undefined),
|
|
13
|
+
save: vi.fn(async () => { }),
|
|
14
|
+
delete: vi.fn(async () => { }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function mockRepo(profiles = []) {
|
|
18
|
+
return {
|
|
19
|
+
list: vi.fn(async () => profiles),
|
|
20
|
+
get: vi.fn(async () => null),
|
|
21
|
+
save: vi.fn(async (p) => p),
|
|
22
|
+
delete: vi.fn(async () => true),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe("initFleetUpdater", () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
it("returns a handle with all components", async () => {
|
|
30
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
31
|
+
expect(handle.poller).toBeDefined();
|
|
32
|
+
expect(handle.updater).toBeDefined();
|
|
33
|
+
expect(handle.orchestrator).toBeDefined();
|
|
34
|
+
expect(handle.snapshotManager).toBeDefined();
|
|
35
|
+
expect(handle.stop).toBeTypeOf("function");
|
|
36
|
+
await handle.stop();
|
|
37
|
+
});
|
|
38
|
+
it("wires poller.onUpdateAvailable to orchestrator", async () => {
|
|
39
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
40
|
+
expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
|
|
41
|
+
await handle.stop();
|
|
42
|
+
});
|
|
43
|
+
it("accepts custom strategy config", async () => {
|
|
44
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
45
|
+
strategy: "immediate",
|
|
46
|
+
snapshotDir: "/tmp/snapshots",
|
|
47
|
+
});
|
|
48
|
+
expect(handle.orchestrator).toBeDefined();
|
|
49
|
+
await handle.stop();
|
|
50
|
+
});
|
|
51
|
+
it("accepts callbacks", async () => {
|
|
52
|
+
const onBotUpdated = vi.fn();
|
|
53
|
+
const onRolloutComplete = vi.fn();
|
|
54
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
55
|
+
onBotUpdated,
|
|
56
|
+
onRolloutComplete,
|
|
57
|
+
});
|
|
58
|
+
expect(handle.orchestrator).toBeDefined();
|
|
59
|
+
await handle.stop();
|
|
60
|
+
});
|
|
61
|
+
it("stop() stops the poller", async () => {
|
|
62
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
63
|
+
const stopSpy = vi.spyOn(handle.poller, "stop");
|
|
64
|
+
await handle.stop();
|
|
65
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
it("filters manual-policy bots from updatable profiles", async () => {
|
|
68
|
+
const repo = mockRepo([
|
|
69
|
+
{ id: "b1", updatePolicy: "nightly" },
|
|
70
|
+
{ id: "b2", updatePolicy: "manual" },
|
|
71
|
+
{ id: "b3", updatePolicy: "on-push" },
|
|
72
|
+
]);
|
|
73
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
|
|
74
|
+
strategy: "immediate",
|
|
75
|
+
});
|
|
76
|
+
const rolloutResult = await handle.orchestrator.rollout();
|
|
77
|
+
// b2 (manual) should be filtered out, b1 and b3 included
|
|
78
|
+
expect(rolloutResult.totalBots).toBe(2);
|
|
79
|
+
await handle.stop();
|
|
80
|
+
});
|
|
81
|
+
it("uses profileRepo for updatable profiles, not profileStore", async () => {
|
|
82
|
+
const store = mockStore();
|
|
83
|
+
const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
|
|
84
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
|
|
85
|
+
strategy: "immediate",
|
|
86
|
+
});
|
|
87
|
+
await handle.orchestrator.rollout();
|
|
88
|
+
// profileRepo.list() was called for updatable profiles
|
|
89
|
+
expect(repo.list).toHaveBeenCalled();
|
|
90
|
+
// profileStore.list() may also be called by ImagePoller — that's expected
|
|
91
|
+
await handle.stop();
|
|
92
|
+
});
|
|
93
|
+
});
|
package/dist/fleet/index.d.ts
CHANGED
package/dist/fleet/index.js
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
|
|
3
|
+
*
|
|
4
|
+
* Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
|
|
5
|
+
* The pipeline detects new image digests, batches updates via a rollout strategy,
|
|
6
|
+
* snapshots volumes before updating, and restores on failure (nuclear rollback).
|
|
7
|
+
*
|
|
8
|
+
* When a new image digest is detected for ANY bot, the orchestrator triggers a
|
|
9
|
+
* fleet-wide rollout across all non-manual bots. This is intentional: the managed
|
|
10
|
+
* Paperclip image is shared across all tenants, so a single digest change means
|
|
11
|
+
* all bots need updating.
|
|
12
|
+
*/
|
|
13
|
+
import type Docker from "dockerode";
|
|
14
|
+
import type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
15
|
+
import type { FleetManager } from "./fleet-manager.js";
|
|
16
|
+
import { ImagePoller } from "./image-poller.js";
|
|
17
|
+
import type { IProfileStore } from "./profile-store.js";
|
|
18
|
+
import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
|
|
19
|
+
import { type RollingWaveOptions } from "./rollout-strategy.js";
|
|
20
|
+
import { ContainerUpdater } from "./updater.js";
|
|
21
|
+
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
22
|
+
export interface FleetUpdaterConfig {
|
|
23
|
+
/** Rollout strategy type. Default: "rolling-wave" */
|
|
24
|
+
strategy?: "rolling-wave" | "single-bot" | "immediate";
|
|
25
|
+
/** Options for RollingWaveStrategy (ignored for other strategies) */
|
|
26
|
+
strategyOptions?: RollingWaveOptions;
|
|
27
|
+
/** Directory for volume snapshots. Default: "/data/fleet/snapshots" */
|
|
28
|
+
snapshotDir?: string;
|
|
29
|
+
/** Called after each bot update */
|
|
30
|
+
onBotUpdated?: (result: {
|
|
31
|
+
botId: string;
|
|
32
|
+
success: boolean;
|
|
33
|
+
volumeRestored: boolean;
|
|
34
|
+
}) => void;
|
|
35
|
+
/** Called when a rollout completes */
|
|
36
|
+
onRolloutComplete?: (result: RolloutResult) => void;
|
|
37
|
+
}
|
|
38
|
+
export interface FleetUpdaterHandle {
|
|
39
|
+
poller: ImagePoller;
|
|
40
|
+
updater: ContainerUpdater;
|
|
41
|
+
orchestrator: RolloutOrchestrator;
|
|
42
|
+
snapshotManager: VolumeSnapshotManager;
|
|
43
|
+
/** Stop the poller and wait for any active rollout to finish */
|
|
44
|
+
stop: () => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Initialize the fleet auto-update pipeline.
|
|
48
|
+
*
|
|
49
|
+
* Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
|
|
50
|
+
* with VolumeSnapshotManager for nuclear rollback.
|
|
51
|
+
*
|
|
52
|
+
* @param docker - Dockerode instance for container operations
|
|
53
|
+
* @param fleet - FleetManager for container lifecycle
|
|
54
|
+
* @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
|
|
55
|
+
* @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
|
|
56
|
+
* @param config - Optional pipeline configuration
|
|
57
|
+
*/
|
|
58
|
+
export declare function initFleetUpdater(docker: Docker, fleet: FleetManager, profileStore: IProfileStore, profileRepo: IBotProfileRepository, config?: FleetUpdaterConfig): FleetUpdaterHandle;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
|
|
3
|
+
*
|
|
4
|
+
* Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
|
|
5
|
+
* The pipeline detects new image digests, batches updates via a rollout strategy,
|
|
6
|
+
* snapshots volumes before updating, and restores on failure (nuclear rollback).
|
|
7
|
+
*
|
|
8
|
+
* When a new image digest is detected for ANY bot, the orchestrator triggers a
|
|
9
|
+
* fleet-wide rollout across all non-manual bots. This is intentional: the managed
|
|
10
|
+
* Paperclip image is shared across all tenants, so a single digest change means
|
|
11
|
+
* all bots need updating.
|
|
12
|
+
*/
|
|
13
|
+
import { logger } from "../config/logger.js";
|
|
14
|
+
import { ImagePoller } from "./image-poller.js";
|
|
15
|
+
import { RolloutOrchestrator } from "./rollout-orchestrator.js";
|
|
16
|
+
import { createRolloutStrategy } from "./rollout-strategy.js";
|
|
17
|
+
import { ContainerUpdater } from "./updater.js";
|
|
18
|
+
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the fleet auto-update pipeline.
|
|
21
|
+
*
|
|
22
|
+
* Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
|
|
23
|
+
* with VolumeSnapshotManager for nuclear rollback.
|
|
24
|
+
*
|
|
25
|
+
* @param docker - Dockerode instance for container operations
|
|
26
|
+
* @param fleet - FleetManager for container lifecycle
|
|
27
|
+
* @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
|
|
28
|
+
* @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
|
|
29
|
+
* @param config - Optional pipeline configuration
|
|
30
|
+
*/
|
|
31
|
+
export function initFleetUpdater(docker, fleet, profileStore, profileRepo, config = {}) {
|
|
32
|
+
const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, } = config;
|
|
33
|
+
const poller = new ImagePoller(docker, profileStore);
|
|
34
|
+
const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
|
|
35
|
+
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
36
|
+
const strategy = createRolloutStrategy(strategyType, strategyOptions);
|
|
37
|
+
const orchestrator = new RolloutOrchestrator({
|
|
38
|
+
updater,
|
|
39
|
+
snapshotManager,
|
|
40
|
+
strategy,
|
|
41
|
+
getUpdatableProfiles: async () => {
|
|
42
|
+
const profiles = await profileRepo.list();
|
|
43
|
+
return profiles.filter((p) => p.updatePolicy !== "manual");
|
|
44
|
+
},
|
|
45
|
+
onBotUpdated,
|
|
46
|
+
onRolloutComplete,
|
|
47
|
+
});
|
|
48
|
+
// Wire the detection → orchestration pipeline.
|
|
49
|
+
// Any digest change triggers a fleet-wide rollout because the managed image
|
|
50
|
+
// is shared across all tenants — one new digest means all bots need updating.
|
|
51
|
+
poller.onUpdateAvailable = async (_botId, _newDigest) => {
|
|
52
|
+
if (orchestrator.isRolling) {
|
|
53
|
+
logger.debug("Skipping update trigger — rollout already in progress");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
logger.info("New image digest detected — starting fleet-wide rollout");
|
|
57
|
+
await orchestrator.rollout().catch((err) => {
|
|
58
|
+
logger.error("Rollout failed", { err });
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
// Start polling
|
|
62
|
+
poller.start().catch((err) => {
|
|
63
|
+
logger.error("ImagePoller failed to start", { err });
|
|
64
|
+
});
|
|
65
|
+
logger.info("Fleet auto-update pipeline initialized", {
|
|
66
|
+
strategy: strategyType,
|
|
67
|
+
snapshotDir,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
poller,
|
|
71
|
+
updater,
|
|
72
|
+
orchestrator,
|
|
73
|
+
snapshotManager,
|
|
74
|
+
stop: async () => {
|
|
75
|
+
poller.stop();
|
|
76
|
+
// Wait for any in-flight rollout to complete before returning
|
|
77
|
+
if (orchestrator.isRolling) {
|
|
78
|
+
logger.info("Waiting for active rollout to finish before shutdown...");
|
|
79
|
+
// Poll until rollout finishes (max 5 minutes)
|
|
80
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
81
|
+
while (orchestrator.isRolling && Date.now() < deadline) {
|
|
82
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
logger.info("Fleet auto-update pipeline stopped");
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { IBotProfileRepository } from "../bot-profile-repository.js";
|
|
4
|
+
import type { FleetManager } from "../fleet-manager.js";
|
|
5
|
+
import { initFleetUpdater } from "../init-fleet-updater.js";
|
|
6
|
+
import type { IProfileStore } from "../profile-store.js";
|
|
7
|
+
|
|
8
|
+
function mockDocker(): Docker {
|
|
9
|
+
return {} as Docker;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockFleet(): FleetManager {
|
|
13
|
+
return {} as FleetManager;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mockStore(): IProfileStore {
|
|
17
|
+
return {
|
|
18
|
+
list: vi.fn(async () => []),
|
|
19
|
+
get: vi.fn(async () => undefined),
|
|
20
|
+
save: vi.fn(async () => {}),
|
|
21
|
+
delete: vi.fn(async () => {}),
|
|
22
|
+
} as unknown as IProfileStore;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mockRepo(profiles: unknown[] = []): IBotProfileRepository {
|
|
26
|
+
return {
|
|
27
|
+
list: vi.fn(async () => profiles),
|
|
28
|
+
get: vi.fn(async () => null),
|
|
29
|
+
save: vi.fn(async (p: unknown) => p),
|
|
30
|
+
delete: vi.fn(async () => true),
|
|
31
|
+
} as unknown as IBotProfileRepository;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("initFleetUpdater", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns a handle with all components", async () => {
|
|
40
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
41
|
+
|
|
42
|
+
expect(handle.poller).toBeDefined();
|
|
43
|
+
expect(handle.updater).toBeDefined();
|
|
44
|
+
expect(handle.orchestrator).toBeDefined();
|
|
45
|
+
expect(handle.snapshotManager).toBeDefined();
|
|
46
|
+
expect(handle.stop).toBeTypeOf("function");
|
|
47
|
+
|
|
48
|
+
await handle.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("wires poller.onUpdateAvailable to orchestrator", async () => {
|
|
52
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
53
|
+
|
|
54
|
+
expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
|
|
55
|
+
|
|
56
|
+
await handle.stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("accepts custom strategy config", async () => {
|
|
60
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
61
|
+
strategy: "immediate",
|
|
62
|
+
snapshotDir: "/tmp/snapshots",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(handle.orchestrator).toBeDefined();
|
|
66
|
+
|
|
67
|
+
await handle.stop();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("accepts callbacks", async () => {
|
|
71
|
+
const onBotUpdated = vi.fn();
|
|
72
|
+
const onRolloutComplete = vi.fn();
|
|
73
|
+
|
|
74
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
75
|
+
onBotUpdated,
|
|
76
|
+
onRolloutComplete,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(handle.orchestrator).toBeDefined();
|
|
80
|
+
|
|
81
|
+
await handle.stop();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stop() stops the poller", async () => {
|
|
85
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
86
|
+
|
|
87
|
+
const stopSpy = vi.spyOn(handle.poller, "stop");
|
|
88
|
+
|
|
89
|
+
await handle.stop();
|
|
90
|
+
|
|
91
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("filters manual-policy bots from updatable profiles", async () => {
|
|
95
|
+
const repo = mockRepo([
|
|
96
|
+
{ id: "b1", updatePolicy: "nightly" },
|
|
97
|
+
{ id: "b2", updatePolicy: "manual" },
|
|
98
|
+
{ id: "b3", updatePolicy: "on-push" },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
|
|
102
|
+
strategy: "immediate",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const rolloutResult = await handle.orchestrator.rollout();
|
|
106
|
+
|
|
107
|
+
// b2 (manual) should be filtered out, b1 and b3 included
|
|
108
|
+
expect(rolloutResult.totalBots).toBe(2);
|
|
109
|
+
|
|
110
|
+
await handle.stop();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uses profileRepo for updatable profiles, not profileStore", async () => {
|
|
114
|
+
const store = mockStore();
|
|
115
|
+
const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
|
|
116
|
+
|
|
117
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
|
|
118
|
+
strategy: "immediate",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await handle.orchestrator.rollout();
|
|
122
|
+
|
|
123
|
+
// profileRepo.list() was called for updatable profiles
|
|
124
|
+
expect(repo.list).toHaveBeenCalled();
|
|
125
|
+
// profileStore.list() may also be called by ImagePoller — that's expected
|
|
126
|
+
|
|
127
|
+
await handle.stop();
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/fleet/index.ts
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
|
|
3
|
+
*
|
|
4
|
+
* Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
|
|
5
|
+
* The pipeline detects new image digests, batches updates via a rollout strategy,
|
|
6
|
+
* snapshots volumes before updating, and restores on failure (nuclear rollback).
|
|
7
|
+
*
|
|
8
|
+
* When a new image digest is detected for ANY bot, the orchestrator triggers a
|
|
9
|
+
* fleet-wide rollout across all non-manual bots. This is intentional: the managed
|
|
10
|
+
* Paperclip image is shared across all tenants, so a single digest change means
|
|
11
|
+
* all bots need updating.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type Docker from "dockerode";
|
|
15
|
+
import { logger } from "../config/logger.js";
|
|
16
|
+
import type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
17
|
+
import type { FleetManager } from "./fleet-manager.js";
|
|
18
|
+
import { ImagePoller } from "./image-poller.js";
|
|
19
|
+
import type { IProfileStore } from "./profile-store.js";
|
|
20
|
+
import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
|
|
21
|
+
import { createRolloutStrategy, type RollingWaveOptions } from "./rollout-strategy.js";
|
|
22
|
+
import { ContainerUpdater } from "./updater.js";
|
|
23
|
+
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
24
|
+
|
|
25
|
+
export interface FleetUpdaterConfig {
|
|
26
|
+
/** Rollout strategy type. Default: "rolling-wave" */
|
|
27
|
+
strategy?: "rolling-wave" | "single-bot" | "immediate";
|
|
28
|
+
/** Options for RollingWaveStrategy (ignored for other strategies) */
|
|
29
|
+
strategyOptions?: RollingWaveOptions;
|
|
30
|
+
/** Directory for volume snapshots. Default: "/data/fleet/snapshots" */
|
|
31
|
+
snapshotDir?: string;
|
|
32
|
+
/** Called after each bot update */
|
|
33
|
+
onBotUpdated?: (result: { botId: string; success: boolean; volumeRestored: boolean }) => void;
|
|
34
|
+
/** Called when a rollout completes */
|
|
35
|
+
onRolloutComplete?: (result: RolloutResult) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FleetUpdaterHandle {
|
|
39
|
+
poller: ImagePoller;
|
|
40
|
+
updater: ContainerUpdater;
|
|
41
|
+
orchestrator: RolloutOrchestrator;
|
|
42
|
+
snapshotManager: VolumeSnapshotManager;
|
|
43
|
+
/** Stop the poller and wait for any active rollout to finish */
|
|
44
|
+
stop: () => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize the fleet auto-update pipeline.
|
|
49
|
+
*
|
|
50
|
+
* Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
|
|
51
|
+
* with VolumeSnapshotManager for nuclear rollback.
|
|
52
|
+
*
|
|
53
|
+
* @param docker - Dockerode instance for container operations
|
|
54
|
+
* @param fleet - FleetManager for container lifecycle
|
|
55
|
+
* @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
|
|
56
|
+
* @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
|
|
57
|
+
* @param config - Optional pipeline configuration
|
|
58
|
+
*/
|
|
59
|
+
export function initFleetUpdater(
|
|
60
|
+
docker: Docker,
|
|
61
|
+
fleet: FleetManager,
|
|
62
|
+
profileStore: IProfileStore,
|
|
63
|
+
profileRepo: IBotProfileRepository,
|
|
64
|
+
config: FleetUpdaterConfig = {},
|
|
65
|
+
): FleetUpdaterHandle {
|
|
66
|
+
const {
|
|
67
|
+
strategy: strategyType = "rolling-wave",
|
|
68
|
+
strategyOptions,
|
|
69
|
+
snapshotDir = "/data/fleet/snapshots",
|
|
70
|
+
onBotUpdated,
|
|
71
|
+
onRolloutComplete,
|
|
72
|
+
} = config;
|
|
73
|
+
|
|
74
|
+
const poller = new ImagePoller(docker, profileStore);
|
|
75
|
+
const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
|
|
76
|
+
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
77
|
+
const strategy = createRolloutStrategy(strategyType, strategyOptions);
|
|
78
|
+
|
|
79
|
+
const orchestrator = new RolloutOrchestrator({
|
|
80
|
+
updater,
|
|
81
|
+
snapshotManager,
|
|
82
|
+
strategy,
|
|
83
|
+
getUpdatableProfiles: async () => {
|
|
84
|
+
const profiles = await profileRepo.list();
|
|
85
|
+
return profiles.filter((p) => p.updatePolicy !== "manual");
|
|
86
|
+
},
|
|
87
|
+
onBotUpdated,
|
|
88
|
+
onRolloutComplete,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wire the detection → orchestration pipeline.
|
|
92
|
+
// Any digest change triggers a fleet-wide rollout because the managed image
|
|
93
|
+
// is shared across all tenants — one new digest means all bots need updating.
|
|
94
|
+
poller.onUpdateAvailable = async (_botId: string, _newDigest: string) => {
|
|
95
|
+
if (orchestrator.isRolling) {
|
|
96
|
+
logger.debug("Skipping update trigger — rollout already in progress");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
logger.info("New image digest detected — starting fleet-wide rollout");
|
|
100
|
+
await orchestrator.rollout().catch((err) => {
|
|
101
|
+
logger.error("Rollout failed", { err });
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Start polling
|
|
106
|
+
poller.start().catch((err) => {
|
|
107
|
+
logger.error("ImagePoller failed to start", { err });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
logger.info("Fleet auto-update pipeline initialized", {
|
|
111
|
+
strategy: strategyType,
|
|
112
|
+
snapshotDir,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
poller,
|
|
117
|
+
updater,
|
|
118
|
+
orchestrator,
|
|
119
|
+
snapshotManager,
|
|
120
|
+
stop: async () => {
|
|
121
|
+
poller.stop();
|
|
122
|
+
// Wait for any in-flight rollout to complete before returning
|
|
123
|
+
if (orchestrator.isRolling) {
|
|
124
|
+
logger.info("Waiting for active rollout to finish before shutdown...");
|
|
125
|
+
// Poll until rollout finishes (max 5 minutes)
|
|
126
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
127
|
+
while (orchestrator.isRolling && Date.now() < deadline) {
|
|
128
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
logger.info("Fleet auto-update pipeline stopped");
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|