@wopr-network/platform-core 1.18.0 → 1.20.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/api/routes/admin-audit-helper.d.ts +1 -1
- package/dist/billing/crypto/btc/__tests__/address-gen.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/address-gen.test.js +44 -0
- package/dist/billing/crypto/btc/__tests__/config.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/config.test.js +24 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +92 -0
- package/dist/billing/crypto/btc/address-gen.d.ts +8 -0
- package/dist/billing/crypto/btc/address-gen.js +34 -0
- package/dist/billing/crypto/btc/checkout.d.ts +21 -0
- package/dist/billing/crypto/btc/checkout.js +42 -0
- package/dist/billing/crypto/btc/config.d.ts +12 -0
- package/dist/billing/crypto/btc/config.js +28 -0
- package/dist/billing/crypto/btc/index.d.ts +9 -0
- package/dist/billing/crypto/btc/index.js +5 -0
- package/dist/billing/crypto/btc/settler.d.ts +23 -0
- package/dist/billing/crypto/btc/settler.js +55 -0
- package/dist/billing/crypto/btc/types.d.ts +23 -0
- package/dist/billing/crypto/btc/types.js +1 -0
- package/dist/billing/crypto/btc/watcher.d.ts +28 -0
- package/dist/billing/crypto/btc/watcher.js +83 -0
- package/dist/billing/crypto/charge-store.d.ts +3 -3
- package/dist/billing/crypto/evm/__tests__/config.test.js +42 -2
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +31 -17
- package/dist/billing/crypto/evm/checkout.js +4 -2
- package/dist/billing/crypto/evm/config.js +73 -0
- package/dist/billing/crypto/evm/types.d.ts +1 -1
- package/dist/billing/crypto/evm/watcher.js +2 -0
- package/dist/billing/crypto/index.d.ts +2 -1
- package/dist/billing/crypto/index.js +1 -0
- package/dist/db/schema/crypto.js +1 -1
- package/dist/fleet/__tests__/rollout-strategy.test.d.ts +1 -0
- package/dist/fleet/__tests__/rollout-strategy.test.js +157 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.d.ts +1 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.js +171 -0
- package/dist/fleet/index.d.ts +2 -0
- package/dist/fleet/index.js +2 -0
- package/dist/fleet/rollout-strategy.d.ts +52 -0
- package/dist/fleet/rollout-strategy.js +91 -0
- package/dist/fleet/volume-snapshot-manager.d.ts +35 -0
- package/dist/fleet/volume-snapshot-manager.js +185 -0
- package/package.json +3 -1
- package/src/api/routes/admin-audit-helper.ts +1 -1
- package/src/billing/crypto/btc/__tests__/address-gen.test.ts +53 -0
- package/src/billing/crypto/btc/__tests__/config.test.ts +28 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +103 -0
- package/src/billing/crypto/btc/address-gen.ts +41 -0
- package/src/billing/crypto/btc/checkout.ts +61 -0
- package/src/billing/crypto/btc/config.ts +33 -0
- package/src/billing/crypto/btc/index.ts +9 -0
- package/src/billing/crypto/btc/settler.ts +74 -0
- package/src/billing/crypto/btc/types.ts +25 -0
- package/src/billing/crypto/btc/watcher.ts +115 -0
- package/src/billing/crypto/charge-store.ts +3 -3
- package/src/billing/crypto/evm/__tests__/config.test.ts +51 -2
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +34 -17
- package/src/billing/crypto/evm/checkout.ts +4 -2
- package/src/billing/crypto/evm/config.ts +73 -0
- package/src/billing/crypto/evm/types.ts +1 -1
- package/src/billing/crypto/evm/watcher.ts +2 -0
- package/src/billing/crypto/index.ts +2 -1
- package/src/db/schema/crypto.ts +1 -1
- package/src/fleet/__tests__/rollout-strategy.test.ts +192 -0
- package/src/fleet/__tests__/volume-snapshot-manager.test.ts +218 -0
- package/src/fleet/index.ts +2 -0
- package/src/fleet/rollout-strategy.ts +128 -0
- package/src/fleet/volume-snapshot-manager.ts +213 -0
- package/src/marketplace/volume-installer.test.ts +8 -2
|
@@ -17,35 +17,38 @@ function mockTransferLog(to, amount, blockNumber) {
|
|
|
17
17
|
}
|
|
18
18
|
describe("EvmWatcher", () => {
|
|
19
19
|
it("parses Transfer log into EvmPaymentEvent", async () => {
|
|
20
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
20
21
|
const events = [];
|
|
21
22
|
const mockRpc = vi
|
|
22
23
|
.fn()
|
|
23
|
-
.mockResolvedValueOnce(`0x${(102).toString(16)}`)
|
|
24
|
-
.mockResolvedValueOnce([mockTransferLog(
|
|
24
|
+
.mockResolvedValueOnce(`0x${(102).toString(16)}`)
|
|
25
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 99)]);
|
|
25
26
|
const watcher = new EvmWatcher({
|
|
26
27
|
chain: "base",
|
|
27
28
|
token: "USDC",
|
|
28
29
|
rpcCall: mockRpc,
|
|
29
30
|
fromBlock: 99,
|
|
31
|
+
watchedAddresses: [toAddr],
|
|
30
32
|
onPayment: (evt) => {
|
|
31
33
|
events.push(evt);
|
|
32
34
|
},
|
|
33
35
|
});
|
|
34
36
|
await watcher.poll();
|
|
35
37
|
expect(events).toHaveLength(1);
|
|
36
|
-
expect(events[0].amountUsdCents).toBe(1000);
|
|
38
|
+
expect(events[0].amountUsdCents).toBe(1000);
|
|
37
39
|
expect(events[0].to).toMatch(/^0x/);
|
|
38
40
|
});
|
|
39
41
|
it("advances cursor after processing", async () => {
|
|
40
42
|
const mockRpc = vi
|
|
41
43
|
.fn()
|
|
42
|
-
.mockResolvedValueOnce(`0x${(200).toString(16)}`)
|
|
43
|
-
.mockResolvedValueOnce([]);
|
|
44
|
+
.mockResolvedValueOnce(`0x${(200).toString(16)}`)
|
|
45
|
+
.mockResolvedValueOnce([]);
|
|
44
46
|
const watcher = new EvmWatcher({
|
|
45
47
|
chain: "base",
|
|
46
48
|
token: "USDC",
|
|
47
49
|
rpcCall: mockRpc,
|
|
48
50
|
fromBlock: 100,
|
|
51
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
49
52
|
onPayment: vi.fn(),
|
|
50
53
|
});
|
|
51
54
|
await watcher.poll();
|
|
@@ -53,37 +56,35 @@ describe("EvmWatcher", () => {
|
|
|
53
56
|
});
|
|
54
57
|
it("skips blocks not yet confirmed", async () => {
|
|
55
58
|
const events = [];
|
|
56
|
-
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
|
|
57
|
-
// Base needs 1 confirmation, so confirmed = 50 - 1 = 49
|
|
58
|
-
// cursor starts at 50, so confirmed (49) < cursor (50) → no poll
|
|
59
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
|
|
59
60
|
const watcher = new EvmWatcher({
|
|
60
61
|
chain: "base",
|
|
61
62
|
token: "USDC",
|
|
62
63
|
rpcCall: mockRpc,
|
|
63
64
|
fromBlock: 50,
|
|
65
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
64
66
|
onPayment: (evt) => {
|
|
65
67
|
events.push(evt);
|
|
66
68
|
},
|
|
67
69
|
});
|
|
68
70
|
await watcher.poll();
|
|
69
71
|
expect(events).toHaveLength(0);
|
|
70
|
-
// eth_getLogs should not even be called
|
|
71
72
|
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
72
73
|
});
|
|
73
74
|
it("processes multiple logs in one poll", async () => {
|
|
75
|
+
const addr1 = `0x${"aa".repeat(20)}`;
|
|
76
|
+
const addr2 = `0x${"bb".repeat(20)}`;
|
|
74
77
|
const events = [];
|
|
75
78
|
const mockRpc = vi
|
|
76
79
|
.fn()
|
|
77
|
-
.mockResolvedValueOnce(`0x${(110).toString(16)}`)
|
|
78
|
-
.mockResolvedValueOnce([
|
|
79
|
-
mockTransferLog(`0x${"aa".repeat(20)}`, 5000000n, 105), // $5
|
|
80
|
-
mockTransferLog(`0x${"bb".repeat(20)}`, 20000000n, 107), // $20
|
|
81
|
-
]);
|
|
80
|
+
.mockResolvedValueOnce(`0x${(110).toString(16)}`)
|
|
81
|
+
.mockResolvedValueOnce([mockTransferLog(addr1, 5000000n, 105), mockTransferLog(addr2, 20000000n, 107)]);
|
|
82
82
|
const watcher = new EvmWatcher({
|
|
83
83
|
chain: "base",
|
|
84
84
|
token: "USDC",
|
|
85
85
|
rpcCall: mockRpc,
|
|
86
86
|
fromBlock: 100,
|
|
87
|
+
watchedAddresses: [addr1, addr2],
|
|
87
88
|
onPayment: (evt) => {
|
|
88
89
|
events.push(evt);
|
|
89
90
|
},
|
|
@@ -94,16 +95,29 @@ describe("EvmWatcher", () => {
|
|
|
94
95
|
expect(events[1].amountUsdCents).toBe(2000);
|
|
95
96
|
});
|
|
96
97
|
it("does nothing when no new blocks", async () => {
|
|
97
|
-
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
|
|
98
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
|
|
98
99
|
const watcher = new EvmWatcher({
|
|
99
100
|
chain: "base",
|
|
100
101
|
token: "USDC",
|
|
101
102
|
rpcCall: mockRpc,
|
|
102
103
|
fromBlock: 100,
|
|
104
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
103
105
|
onPayment: vi.fn(),
|
|
104
106
|
});
|
|
105
107
|
await watcher.poll();
|
|
106
|
-
expect(watcher.cursor).toBe(100);
|
|
107
|
-
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(watcher.cursor).toBe(100);
|
|
109
|
+
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
it("early-returns when no watched addresses are set", async () => {
|
|
112
|
+
const mockRpc = vi.fn();
|
|
113
|
+
const watcher = new EvmWatcher({
|
|
114
|
+
chain: "base",
|
|
115
|
+
token: "USDC",
|
|
116
|
+
rpcCall: mockRpc,
|
|
117
|
+
fromBlock: 0,
|
|
118
|
+
onPayment: vi.fn(),
|
|
119
|
+
});
|
|
120
|
+
await watcher.poll();
|
|
121
|
+
expect(mockRpc).not.toHaveBeenCalled(); // no RPC calls at all
|
|
108
122
|
});
|
|
109
123
|
});
|
|
@@ -47,8 +47,10 @@ export async function createStablecoinCheckout(deps, opts) {
|
|
|
47
47
|
catch (err) {
|
|
48
48
|
// Unique constraint violation = another checkout claimed this index concurrently.
|
|
49
49
|
// Retry with the next available index.
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// PostgreSQL error code 23505 = unique_violation.
|
|
51
|
+
// Check structured code first, fall back to message for other drivers.
|
|
52
|
+
const code = err.code;
|
|
53
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
52
54
|
if (!isConflict || attempt === maxRetries)
|
|
53
55
|
throw err;
|
|
54
56
|
}
|
|
@@ -6,8 +6,30 @@ const CHAINS = {
|
|
|
6
6
|
blockTimeMs: 2000,
|
|
7
7
|
chainId: 8453,
|
|
8
8
|
},
|
|
9
|
+
ethereum: {
|
|
10
|
+
chain: "ethereum",
|
|
11
|
+
rpcUrl: process.env.EVM_RPC_ETHEREUM ?? "http://geth:8545",
|
|
12
|
+
confirmations: 12,
|
|
13
|
+
blockTimeMs: 12000,
|
|
14
|
+
chainId: 1,
|
|
15
|
+
},
|
|
16
|
+
arbitrum: {
|
|
17
|
+
chain: "arbitrum",
|
|
18
|
+
rpcUrl: process.env.EVM_RPC_ARBITRUM ?? "http://nitro:8547",
|
|
19
|
+
confirmations: 1,
|
|
20
|
+
blockTimeMs: 250,
|
|
21
|
+
chainId: 42161,
|
|
22
|
+
},
|
|
23
|
+
polygon: {
|
|
24
|
+
chain: "polygon",
|
|
25
|
+
rpcUrl: process.env.EVM_RPC_POLYGON ?? "http://bor:8545",
|
|
26
|
+
confirmations: 32,
|
|
27
|
+
blockTimeMs: 2000,
|
|
28
|
+
chainId: 137,
|
|
29
|
+
},
|
|
9
30
|
};
|
|
10
31
|
const TOKENS = {
|
|
32
|
+
// --- Base ---
|
|
11
33
|
"USDC:base": {
|
|
12
34
|
token: "USDC",
|
|
13
35
|
chain: "base",
|
|
@@ -26,6 +48,57 @@ const TOKENS = {
|
|
|
26
48
|
contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
|
27
49
|
decimals: 18,
|
|
28
50
|
},
|
|
51
|
+
// --- Ethereum ---
|
|
52
|
+
"USDC:ethereum": {
|
|
53
|
+
token: "USDC",
|
|
54
|
+
chain: "ethereum",
|
|
55
|
+
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
56
|
+
decimals: 6,
|
|
57
|
+
},
|
|
58
|
+
"USDT:ethereum": {
|
|
59
|
+
token: "USDT",
|
|
60
|
+
chain: "ethereum",
|
|
61
|
+
contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
|
62
|
+
decimals: 6,
|
|
63
|
+
},
|
|
64
|
+
"DAI:ethereum": {
|
|
65
|
+
token: "DAI",
|
|
66
|
+
chain: "ethereum",
|
|
67
|
+
contractAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
|
68
|
+
decimals: 18,
|
|
69
|
+
},
|
|
70
|
+
// --- Arbitrum ---
|
|
71
|
+
"USDC:arbitrum": {
|
|
72
|
+
token: "USDC",
|
|
73
|
+
chain: "arbitrum",
|
|
74
|
+
contractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
75
|
+
decimals: 6,
|
|
76
|
+
},
|
|
77
|
+
"USDT:arbitrum": {
|
|
78
|
+
token: "USDT",
|
|
79
|
+
chain: "arbitrum",
|
|
80
|
+
contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
|
|
81
|
+
decimals: 6,
|
|
82
|
+
},
|
|
83
|
+
"DAI:arbitrum": {
|
|
84
|
+
token: "DAI",
|
|
85
|
+
chain: "arbitrum",
|
|
86
|
+
contractAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
|
|
87
|
+
decimals: 18,
|
|
88
|
+
},
|
|
89
|
+
// --- Polygon ---
|
|
90
|
+
"USDC:polygon": {
|
|
91
|
+
token: "USDC",
|
|
92
|
+
chain: "polygon",
|
|
93
|
+
contractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
94
|
+
decimals: 6,
|
|
95
|
+
},
|
|
96
|
+
"USDT:polygon": {
|
|
97
|
+
token: "USDT",
|
|
98
|
+
chain: "polygon",
|
|
99
|
+
contractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
|
|
100
|
+
decimals: 6,
|
|
101
|
+
},
|
|
29
102
|
};
|
|
30
103
|
export function getChainConfig(chain) {
|
|
31
104
|
const cfg = CHAINS[chain];
|
|
@@ -32,6 +32,8 @@ export class EvmWatcher {
|
|
|
32
32
|
}
|
|
33
33
|
/** Poll for new Transfer events. Call on an interval. */
|
|
34
34
|
async poll() {
|
|
35
|
+
if (this._watchedAddresses.length === 0)
|
|
36
|
+
return; // nothing to watch
|
|
35
37
|
const latestHex = (await this.rpc("eth_blockNumber", []));
|
|
36
38
|
const latest = Number.parseInt(latestHex, 16);
|
|
37
39
|
const confirmed = latest - this.confirmations;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from "./btc/index.js";
|
|
2
|
+
export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
|
|
2
3
|
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
3
4
|
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
4
5
|
export type { CryptoConfig } from "./client.js";
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -27,6 +27,6 @@ export const cryptoCharges = pgTable("crypto_charges", {
|
|
|
27
27
|
index("idx_crypto_charges_status").on(table.status),
|
|
28
28
|
index("idx_crypto_charges_created").on(table.createdAt),
|
|
29
29
|
index("idx_crypto_charges_deposit_address").on(table.depositAddress),
|
|
30
|
-
//
|
|
30
|
+
// Unique indexes use WHERE IS NOT NULL partial indexes (declared in migration SQL).
|
|
31
31
|
// Enforced via migration: CREATE UNIQUE INDEX.
|
|
32
32
|
]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createRolloutStrategy, ImmediateStrategy, RollingWaveStrategy, SingleBotStrategy, } from "../rollout-strategy.js";
|
|
4
|
+
function makeBots(count) {
|
|
5
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
6
|
+
id: randomUUID(),
|
|
7
|
+
tenantId: "tenant-1",
|
|
8
|
+
name: `bot-${i}`,
|
|
9
|
+
description: "",
|
|
10
|
+
image: "ghcr.io/wopr-network/test:latest",
|
|
11
|
+
env: {},
|
|
12
|
+
restartPolicy: "unless-stopped",
|
|
13
|
+
releaseChannel: "stable",
|
|
14
|
+
updatePolicy: "manual",
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
describe("RollingWaveStrategy", () => {
|
|
18
|
+
it("returns batchPercent of remaining bots", () => {
|
|
19
|
+
const s = new RollingWaveStrategy({ batchPercent: 25 });
|
|
20
|
+
const bots = makeBots(10);
|
|
21
|
+
const batch = s.nextBatch(bots);
|
|
22
|
+
// 25% of 10 = 2.5, ceil = 3
|
|
23
|
+
expect(batch).toHaveLength(3);
|
|
24
|
+
expect(batch).toEqual(bots.slice(0, 3));
|
|
25
|
+
});
|
|
26
|
+
it("returns minimum 1 bot even with low percentage", () => {
|
|
27
|
+
const s = new RollingWaveStrategy({ batchPercent: 1 });
|
|
28
|
+
const bots = makeBots(2);
|
|
29
|
+
expect(s.nextBatch(bots)).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
it("returns empty array for empty remaining", () => {
|
|
32
|
+
const s = new RollingWaveStrategy();
|
|
33
|
+
expect(s.nextBatch([])).toHaveLength(0);
|
|
34
|
+
});
|
|
35
|
+
it("handles 1 bot remaining", () => {
|
|
36
|
+
const s = new RollingWaveStrategy({ batchPercent: 50 });
|
|
37
|
+
const bots = makeBots(1);
|
|
38
|
+
expect(s.nextBatch(bots)).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
it("handles batchPercent > 100", () => {
|
|
41
|
+
const s = new RollingWaveStrategy({ batchPercent: 200 });
|
|
42
|
+
const bots = makeBots(5);
|
|
43
|
+
// 200% of 5 = 10, ceil = 10, but slice(0,10) on 5 items = 5
|
|
44
|
+
expect(s.nextBatch(bots)).toHaveLength(5);
|
|
45
|
+
});
|
|
46
|
+
it("returns correct pause duration", () => {
|
|
47
|
+
expect(new RollingWaveStrategy().pauseDuration()).toBe(60_000);
|
|
48
|
+
expect(new RollingWaveStrategy({ pauseMs: 30_000 }).pauseDuration()).toBe(30_000);
|
|
49
|
+
});
|
|
50
|
+
it("retries on failure until maxRetries", () => {
|
|
51
|
+
const s = new RollingWaveStrategy({ maxFailures: 2 });
|
|
52
|
+
const err = new Error("fail");
|
|
53
|
+
expect(s.onBotFailure("b1", err, 0)).toBe("retry");
|
|
54
|
+
expect(s.onBotFailure("b1", err, 1)).toBe("retry");
|
|
55
|
+
});
|
|
56
|
+
it("skips after maxRetries when under maxFailures", () => {
|
|
57
|
+
const s = new RollingWaveStrategy({ maxFailures: 3 });
|
|
58
|
+
const err = new Error("fail");
|
|
59
|
+
// attempt >= maxRetries (2), first total failure
|
|
60
|
+
expect(s.onBotFailure("b1", err, 2)).toBe("skip");
|
|
61
|
+
});
|
|
62
|
+
it("aborts when total failures reach maxFailures", () => {
|
|
63
|
+
const s = new RollingWaveStrategy({ maxFailures: 2 });
|
|
64
|
+
const err = new Error("fail");
|
|
65
|
+
// exhaust retries for bot1 → skip (totalFailures=1)
|
|
66
|
+
expect(s.onBotFailure("b1", err, 2)).toBe("skip");
|
|
67
|
+
// exhaust retries for bot2 → abort (totalFailures=2 >= maxFailures=2)
|
|
68
|
+
expect(s.onBotFailure("b2", err, 2)).toBe("abort");
|
|
69
|
+
});
|
|
70
|
+
it("has maxRetries of 2", () => {
|
|
71
|
+
expect(new RollingWaveStrategy().maxRetries()).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
it("has healthCheckTimeout of 120_000", () => {
|
|
74
|
+
expect(new RollingWaveStrategy().healthCheckTimeout()).toBe(120_000);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("SingleBotStrategy", () => {
|
|
78
|
+
it("returns exactly 1 bot", () => {
|
|
79
|
+
const s = new SingleBotStrategy();
|
|
80
|
+
const bots = makeBots(10);
|
|
81
|
+
expect(s.nextBatch(bots)).toHaveLength(1);
|
|
82
|
+
expect(s.nextBatch(bots)[0]).toBe(bots[0]);
|
|
83
|
+
});
|
|
84
|
+
it("returns empty for empty remaining", () => {
|
|
85
|
+
expect(new SingleBotStrategy().nextBatch([])).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
it("has pauseDuration of 0", () => {
|
|
88
|
+
expect(new SingleBotStrategy().pauseDuration()).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it("always retries on failure", () => {
|
|
91
|
+
const s = new SingleBotStrategy();
|
|
92
|
+
const err = new Error("fail");
|
|
93
|
+
expect(s.onBotFailure("b1", err, 0)).toBe("retry");
|
|
94
|
+
expect(s.onBotFailure("b1", err, 1)).toBe("retry");
|
|
95
|
+
expect(s.onBotFailure("b1", err, 2)).toBe("retry");
|
|
96
|
+
// After maxRetries (3), aborts instead of retrying forever
|
|
97
|
+
expect(s.onBotFailure("b1", err, 99)).toBe("abort");
|
|
98
|
+
});
|
|
99
|
+
it("has maxRetries of 3", () => {
|
|
100
|
+
expect(new SingleBotStrategy().maxRetries()).toBe(3);
|
|
101
|
+
});
|
|
102
|
+
it("has healthCheckTimeout of 120_000", () => {
|
|
103
|
+
expect(new SingleBotStrategy().healthCheckTimeout()).toBe(120_000);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("ImmediateStrategy", () => {
|
|
107
|
+
it("returns all remaining bots", () => {
|
|
108
|
+
const s = new ImmediateStrategy();
|
|
109
|
+
const bots = makeBots(10);
|
|
110
|
+
expect(s.nextBatch(bots)).toHaveLength(10);
|
|
111
|
+
expect(s.nextBatch(bots)).toEqual(bots);
|
|
112
|
+
});
|
|
113
|
+
it("returns empty for empty remaining", () => {
|
|
114
|
+
expect(new ImmediateStrategy().nextBatch([])).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
it("does not mutate the input array", () => {
|
|
117
|
+
const s = new ImmediateStrategy();
|
|
118
|
+
const bots = makeBots(3);
|
|
119
|
+
const result = s.nextBatch(bots);
|
|
120
|
+
expect(result).not.toBe(bots);
|
|
121
|
+
expect(result).toEqual(bots);
|
|
122
|
+
});
|
|
123
|
+
it("has pauseDuration of 0", () => {
|
|
124
|
+
expect(new ImmediateStrategy().pauseDuration()).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
it("always skips on failure", () => {
|
|
127
|
+
const s = new ImmediateStrategy();
|
|
128
|
+
const err = new Error("fail");
|
|
129
|
+
expect(s.onBotFailure("b1", err, 0)).toBe("skip");
|
|
130
|
+
expect(s.onBotFailure("b1", err, 5)).toBe("skip");
|
|
131
|
+
});
|
|
132
|
+
it("has maxRetries of 1", () => {
|
|
133
|
+
expect(new ImmediateStrategy().maxRetries()).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
it("has healthCheckTimeout of 60_000", () => {
|
|
136
|
+
expect(new ImmediateStrategy().healthCheckTimeout()).toBe(60_000);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe("createRolloutStrategy", () => {
|
|
140
|
+
it("creates RollingWaveStrategy", () => {
|
|
141
|
+
const s = createRolloutStrategy("rolling-wave");
|
|
142
|
+
expect(s).toBeInstanceOf(RollingWaveStrategy);
|
|
143
|
+
});
|
|
144
|
+
it("creates RollingWaveStrategy with options", () => {
|
|
145
|
+
const s = createRolloutStrategy("rolling-wave", { batchPercent: 50, pauseMs: 10_000 });
|
|
146
|
+
expect(s).toBeInstanceOf(RollingWaveStrategy);
|
|
147
|
+
expect(s.pauseDuration()).toBe(10_000);
|
|
148
|
+
});
|
|
149
|
+
it("creates SingleBotStrategy", () => {
|
|
150
|
+
const s = createRolloutStrategy("single-bot");
|
|
151
|
+
expect(s).toBeInstanceOf(SingleBotStrategy);
|
|
152
|
+
});
|
|
153
|
+
it("creates ImmediateStrategy", () => {
|
|
154
|
+
const s = createRolloutStrategy("immediate");
|
|
155
|
+
expect(s).toBeInstanceOf(ImmediateStrategy);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { VolumeSnapshotManager } from "../volume-snapshot-manager.js";
|
|
3
|
+
// Mock fs/promises
|
|
4
|
+
vi.mock("node:fs/promises", () => ({
|
|
5
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
6
|
+
stat: vi.fn().mockResolvedValue({ size: 1024, mtime: new Date("2026-03-14T10:00:00Z") }),
|
|
7
|
+
readdir: vi.fn().mockResolvedValue([]),
|
|
8
|
+
rm: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
}));
|
|
10
|
+
// Mock logger
|
|
11
|
+
vi.mock("../../config/logger.js", () => ({
|
|
12
|
+
logger: {
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
warn: vi.fn(),
|
|
15
|
+
error: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
20
|
+
function mockContainer() {
|
|
21
|
+
return {
|
|
22
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
wait: vi.fn().mockResolvedValue({ StatusCode: 0 }),
|
|
24
|
+
remove: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function mockDocker() {
|
|
28
|
+
return {
|
|
29
|
+
createContainer: vi.fn().mockResolvedValue(mockContainer()),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
describe("VolumeSnapshotManager", () => {
|
|
33
|
+
let docker;
|
|
34
|
+
let manager;
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
docker = mockDocker();
|
|
38
|
+
manager = new VolumeSnapshotManager(docker, "/data/fleet/snapshots");
|
|
39
|
+
});
|
|
40
|
+
describe("snapshot()", () => {
|
|
41
|
+
it("creates container with correct binds and runs tar", async () => {
|
|
42
|
+
await manager.snapshot("my-volume");
|
|
43
|
+
expect(docker.createContainer).toHaveBeenCalledWith(expect.objectContaining({
|
|
44
|
+
Image: "alpine:latest",
|
|
45
|
+
Cmd: expect.arrayContaining(["tar", "cf"]),
|
|
46
|
+
HostConfig: expect.objectContaining({
|
|
47
|
+
Binds: expect.arrayContaining(["my-volume:/source:ro", "/data/fleet/snapshots:/backup"]),
|
|
48
|
+
AutoRemove: true,
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
const container = await docker.createContainer.mock.results[0].value;
|
|
52
|
+
expect(container.start).toHaveBeenCalled();
|
|
53
|
+
expect(container.wait).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it("returns a VolumeSnapshot with correct fields", async () => {
|
|
56
|
+
const result = await manager.snapshot("my-volume");
|
|
57
|
+
expect(result.volumeName).toBe("my-volume");
|
|
58
|
+
expect(result.id).toMatch(/^my-volume-\d{4}-\d{2}-\d{2}T/);
|
|
59
|
+
expect(result.archivePath).toMatch(/^\/data\/fleet\/snapshots\/my-volume-.*\.tar$/);
|
|
60
|
+
expect(result.sizeBytes).toBe(1024);
|
|
61
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
62
|
+
});
|
|
63
|
+
it("ensures backup directory exists", async () => {
|
|
64
|
+
await manager.snapshot("my-volume");
|
|
65
|
+
expect(mkdir).toHaveBeenCalledWith("/data/fleet/snapshots", { recursive: true });
|
|
66
|
+
});
|
|
67
|
+
it("cleans up container on start failure", async () => {
|
|
68
|
+
const container = mockContainer();
|
|
69
|
+
container.start.mockRejectedValue(new Error("start failed"));
|
|
70
|
+
docker.createContainer.mockResolvedValue(container);
|
|
71
|
+
await expect(manager.snapshot("my-volume")).rejects.toThrow("start failed");
|
|
72
|
+
expect(container.remove).toHaveBeenCalledWith({ force: true });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("restore()", () => {
|
|
76
|
+
it("creates container with correct binds and runs tar xf", async () => {
|
|
77
|
+
const snapshotId = "my-volume-2026-03-14T10-00-00-000Z";
|
|
78
|
+
await manager.restore(snapshotId);
|
|
79
|
+
expect(docker.createContainer).toHaveBeenCalledWith(expect.objectContaining({
|
|
80
|
+
Image: "alpine:latest",
|
|
81
|
+
Cmd: ["sh", "-c", `cd /target && rm -rf ./* ./.??* && tar xf /backup/${snapshotId}.tar -C /target`],
|
|
82
|
+
HostConfig: expect.objectContaining({
|
|
83
|
+
Binds: expect.arrayContaining(["my-volume:/target", "/data/fleet/snapshots:/backup:ro"]),
|
|
84
|
+
AutoRemove: true,
|
|
85
|
+
}),
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it("starts and waits for container", async () => {
|
|
89
|
+
const container = mockContainer();
|
|
90
|
+
docker.createContainer.mockResolvedValue(container);
|
|
91
|
+
await manager.restore("my-volume-2026-03-14T10-00-00-000Z");
|
|
92
|
+
expect(container.start).toHaveBeenCalled();
|
|
93
|
+
expect(container.wait).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it("throws if archive does not exist", async () => {
|
|
96
|
+
stat.mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
97
|
+
await expect(manager.restore("nonexistent-2026-03-14T10-00-00-000Z")).rejects.toThrow("ENOENT");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("list()", () => {
|
|
101
|
+
it("returns snapshots sorted by date, newest first", async () => {
|
|
102
|
+
readdir.mockResolvedValue([
|
|
103
|
+
"my-volume-2026-03-12T08-00-00-000Z.tar",
|
|
104
|
+
"my-volume-2026-03-14T10-00-00-000Z.tar",
|
|
105
|
+
"my-volume-2026-03-13T09-00-00-000Z.tar",
|
|
106
|
+
]);
|
|
107
|
+
const oldDate = new Date("2026-03-12T08:00:00Z");
|
|
108
|
+
const midDate = new Date("2026-03-13T09:00:00Z");
|
|
109
|
+
const newDate = new Date("2026-03-14T10:00:00Z");
|
|
110
|
+
stat
|
|
111
|
+
.mockResolvedValueOnce({ size: 100, mtime: oldDate })
|
|
112
|
+
.mockResolvedValueOnce({ size: 300, mtime: newDate })
|
|
113
|
+
.mockResolvedValueOnce({ size: 200, mtime: midDate });
|
|
114
|
+
const result = await manager.list("my-volume");
|
|
115
|
+
expect(result).toHaveLength(3);
|
|
116
|
+
expect(result[0].createdAt).toEqual(newDate);
|
|
117
|
+
expect(result[1].createdAt).toEqual(midDate);
|
|
118
|
+
expect(result[2].createdAt).toEqual(oldDate);
|
|
119
|
+
});
|
|
120
|
+
it("filters to only matching volume name", async () => {
|
|
121
|
+
readdir.mockResolvedValue([
|
|
122
|
+
"my-volume-2026-03-14T10-00-00-000Z.tar",
|
|
123
|
+
"other-volume-2026-03-14T10-00-00-000Z.tar",
|
|
124
|
+
]);
|
|
125
|
+
const result = await manager.list("my-volume");
|
|
126
|
+
expect(result).toHaveLength(1);
|
|
127
|
+
expect(result[0].volumeName).toBe("my-volume");
|
|
128
|
+
});
|
|
129
|
+
it("returns empty array when backup dir does not exist", async () => {
|
|
130
|
+
readdir.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
131
|
+
const result = await manager.list("my-volume");
|
|
132
|
+
expect(result).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe("delete()", () => {
|
|
136
|
+
it("removes the archive file", async () => {
|
|
137
|
+
await manager.delete("my-volume-2026-03-14T10-00-00-000Z");
|
|
138
|
+
expect(rm).toHaveBeenCalledWith("/data/fleet/snapshots/my-volume-2026-03-14T10-00-00-000Z.tar", { force: true });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("cleanup()", () => {
|
|
142
|
+
it("removes old snapshots and keeps recent ones", async () => {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const oldTime = new Date(now - 2 * 60 * 60 * 1000); // 2 hours ago
|
|
145
|
+
const recentTime = new Date(now - 10 * 60 * 1000); // 10 minutes ago
|
|
146
|
+
readdir.mockResolvedValue([
|
|
147
|
+
"vol-2026-03-14T08-00-00-000Z.tar",
|
|
148
|
+
"vol-2026-03-14T09-50-00-000Z.tar",
|
|
149
|
+
]);
|
|
150
|
+
stat
|
|
151
|
+
.mockResolvedValueOnce({ size: 100, mtime: oldTime })
|
|
152
|
+
.mockResolvedValueOnce({ size: 200, mtime: recentTime });
|
|
153
|
+
const maxAge = 60 * 60 * 1000; // 1 hour
|
|
154
|
+
const deleted = await manager.cleanup(maxAge);
|
|
155
|
+
expect(deleted).toBe(1);
|
|
156
|
+
expect(rm).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(rm).toHaveBeenCalledWith("/data/fleet/snapshots/vol-2026-03-14T08-00-00-000Z.tar", { force: true });
|
|
158
|
+
});
|
|
159
|
+
it("returns 0 when backup dir does not exist", async () => {
|
|
160
|
+
readdir.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
161
|
+
const result = await manager.cleanup(60_000);
|
|
162
|
+
expect(result).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
it("skips non-tar files", async () => {
|
|
165
|
+
readdir.mockResolvedValue(["readme.txt", ".gitkeep"]);
|
|
166
|
+
const result = await manager.cleanup(60_000);
|
|
167
|
+
expect(result).toBe(0);
|
|
168
|
+
expect(stat).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
package/dist/fleet/index.d.ts
CHANGED
package/dist/fleet/index.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BotProfile } from "./types.js";
|
|
2
|
+
export interface IRolloutStrategy {
|
|
3
|
+
/** Select next batch from remaining bots */
|
|
4
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
5
|
+
/** Milliseconds to wait between waves */
|
|
6
|
+
pauseDuration(): number;
|
|
7
|
+
/** What to do when a single bot update fails */
|
|
8
|
+
onBotFailure(botId: string, error: Error, attempt: number): "abort" | "skip" | "retry";
|
|
9
|
+
/** Max retries per bot before skip/abort */
|
|
10
|
+
maxRetries(): number;
|
|
11
|
+
/** Health check timeout per bot (ms) */
|
|
12
|
+
healthCheckTimeout(): number;
|
|
13
|
+
}
|
|
14
|
+
export interface RollingWaveOptions {
|
|
15
|
+
batchPercent?: number;
|
|
16
|
+
pauseMs?: number;
|
|
17
|
+
maxFailures?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Rolling wave strategy — processes bots in configurable percentage batches.
|
|
21
|
+
* Create a new instance per rollout; totalFailures accumulates across waves
|
|
22
|
+
* within a single rollout. Call reset() if reusing across rollouts.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RollingWaveStrategy implements IRolloutStrategy {
|
|
25
|
+
private readonly batchPercent;
|
|
26
|
+
private readonly pauseMs;
|
|
27
|
+
private readonly maxFailures;
|
|
28
|
+
private totalFailures;
|
|
29
|
+
constructor(opts?: RollingWaveOptions);
|
|
30
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
31
|
+
pauseDuration(): number;
|
|
32
|
+
onBotFailure(_botId: string, _error: Error, attempt: number): "abort" | "skip" | "retry";
|
|
33
|
+
maxRetries(): number;
|
|
34
|
+
healthCheckTimeout(): number;
|
|
35
|
+
/** Reset failure counters for reuse across rollouts. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
}
|
|
38
|
+
export declare class SingleBotStrategy implements IRolloutStrategy {
|
|
39
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
40
|
+
pauseDuration(): number;
|
|
41
|
+
onBotFailure(_botId: string, _error: Error, attempt: number): "abort" | "skip" | "retry";
|
|
42
|
+
maxRetries(): number;
|
|
43
|
+
healthCheckTimeout(): number;
|
|
44
|
+
}
|
|
45
|
+
export declare class ImmediateStrategy implements IRolloutStrategy {
|
|
46
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
47
|
+
pauseDuration(): number;
|
|
48
|
+
onBotFailure(_botId: string, _error: Error, _attempt: number): "abort" | "skip" | "retry";
|
|
49
|
+
maxRetries(): number;
|
|
50
|
+
healthCheckTimeout(): number;
|
|
51
|
+
}
|
|
52
|
+
export declare function createRolloutStrategy(type: "rolling-wave" | "single-bot" | "immediate", options?: RollingWaveOptions): IRolloutStrategy;
|