@wopr-network/platform-core 1.17.0 → 1.19.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/evm/__tests__/config.test.js +10 -0
- package/dist/billing/crypto/evm/config.js +12 -0
- package/dist/billing/crypto/evm/types.d.ts +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/docs/superpowers/specs/2026-03-14-fleet-auto-update-design.md +300 -0
- package/docs/superpowers/specs/2026-03-14-paperclip-org-integration-design.md +359 -0
- package/docs/superpowers/specs/2026-03-14-role-permissions-design.md +346 -0
- package/package.json +1 -1
- package/src/api/routes/admin-audit-helper.ts +1 -1
- package/src/billing/crypto/evm/__tests__/config.test.ts +12 -0
- package/src/billing/crypto/evm/config.ts +13 -1
- package/src/billing/crypto/evm/types.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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { VolumeSnapshotManager } from "../volume-snapshot-manager.js";
|
|
4
|
+
|
|
5
|
+
// Mock fs/promises
|
|
6
|
+
vi.mock("node:fs/promises", () => ({
|
|
7
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
stat: vi.fn().mockResolvedValue({ size: 1024, mtime: new Date("2026-03-14T10:00:00Z") }),
|
|
9
|
+
readdir: vi.fn().mockResolvedValue([]),
|
|
10
|
+
rm: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock logger
|
|
14
|
+
vi.mock("../../config/logger.js", () => ({
|
|
15
|
+
logger: {
|
|
16
|
+
info: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
debug: vi.fn(),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
24
|
+
|
|
25
|
+
function mockContainer() {
|
|
26
|
+
return {
|
|
27
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
wait: vi.fn().mockResolvedValue({ StatusCode: 0 }),
|
|
29
|
+
remove: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mockDocker(): Docker {
|
|
34
|
+
return {
|
|
35
|
+
createContainer: vi.fn().mockResolvedValue(mockContainer()),
|
|
36
|
+
} as unknown as Docker;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("VolumeSnapshotManager", () => {
|
|
40
|
+
let docker: Docker;
|
|
41
|
+
let manager: VolumeSnapshotManager;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
docker = mockDocker();
|
|
46
|
+
manager = new VolumeSnapshotManager(docker, "/data/fleet/snapshots");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("snapshot()", () => {
|
|
50
|
+
it("creates container with correct binds and runs tar", async () => {
|
|
51
|
+
await manager.snapshot("my-volume");
|
|
52
|
+
|
|
53
|
+
expect(docker.createContainer).toHaveBeenCalledWith(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
Image: "alpine:latest",
|
|
56
|
+
Cmd: expect.arrayContaining(["tar", "cf"]),
|
|
57
|
+
HostConfig: expect.objectContaining({
|
|
58
|
+
Binds: expect.arrayContaining(["my-volume:/source:ro", "/data/fleet/snapshots:/backup"]),
|
|
59
|
+
AutoRemove: true,
|
|
60
|
+
}),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const container = await (docker.createContainer as ReturnType<typeof vi.fn>).mock.results[0].value;
|
|
65
|
+
expect(container.start).toHaveBeenCalled();
|
|
66
|
+
expect(container.wait).toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns a VolumeSnapshot with correct fields", async () => {
|
|
70
|
+
const result = await manager.snapshot("my-volume");
|
|
71
|
+
|
|
72
|
+
expect(result.volumeName).toBe("my-volume");
|
|
73
|
+
expect(result.id).toMatch(/^my-volume-\d{4}-\d{2}-\d{2}T/);
|
|
74
|
+
expect(result.archivePath).toMatch(/^\/data\/fleet\/snapshots\/my-volume-.*\.tar$/);
|
|
75
|
+
expect(result.sizeBytes).toBe(1024);
|
|
76
|
+
expect(result.createdAt).toBeInstanceOf(Date);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ensures backup directory exists", async () => {
|
|
80
|
+
await manager.snapshot("my-volume");
|
|
81
|
+
expect(mkdir).toHaveBeenCalledWith("/data/fleet/snapshots", { recursive: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("cleans up container on start failure", async () => {
|
|
85
|
+
const container = mockContainer();
|
|
86
|
+
container.start.mockRejectedValue(new Error("start failed"));
|
|
87
|
+
(docker.createContainer as ReturnType<typeof vi.fn>).mockResolvedValue(container);
|
|
88
|
+
|
|
89
|
+
await expect(manager.snapshot("my-volume")).rejects.toThrow("start failed");
|
|
90
|
+
expect(container.remove).toHaveBeenCalledWith({ force: true });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("restore()", () => {
|
|
95
|
+
it("creates container with correct binds and runs tar xf", async () => {
|
|
96
|
+
const snapshotId = "my-volume-2026-03-14T10-00-00-000Z";
|
|
97
|
+
await manager.restore(snapshotId);
|
|
98
|
+
|
|
99
|
+
expect(docker.createContainer).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
Image: "alpine:latest",
|
|
102
|
+
Cmd: ["sh", "-c", `cd /target && rm -rf ./* ./.??* && tar xf /backup/${snapshotId}.tar -C /target`],
|
|
103
|
+
HostConfig: expect.objectContaining({
|
|
104
|
+
Binds: expect.arrayContaining(["my-volume:/target", "/data/fleet/snapshots:/backup:ro"]),
|
|
105
|
+
AutoRemove: true,
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("starts and waits for container", async () => {
|
|
112
|
+
const container = mockContainer();
|
|
113
|
+
(docker.createContainer as ReturnType<typeof vi.fn>).mockResolvedValue(container);
|
|
114
|
+
|
|
115
|
+
await manager.restore("my-volume-2026-03-14T10-00-00-000Z");
|
|
116
|
+
|
|
117
|
+
expect(container.start).toHaveBeenCalled();
|
|
118
|
+
expect(container.wait).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws if archive does not exist", async () => {
|
|
122
|
+
(stat as ReturnType<typeof vi.fn>).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
123
|
+
|
|
124
|
+
await expect(manager.restore("nonexistent-2026-03-14T10-00-00-000Z")).rejects.toThrow("ENOENT");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("list()", () => {
|
|
129
|
+
it("returns snapshots sorted by date, newest first", async () => {
|
|
130
|
+
(readdir as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
131
|
+
"my-volume-2026-03-12T08-00-00-000Z.tar",
|
|
132
|
+
"my-volume-2026-03-14T10-00-00-000Z.tar",
|
|
133
|
+
"my-volume-2026-03-13T09-00-00-000Z.tar",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const oldDate = new Date("2026-03-12T08:00:00Z");
|
|
137
|
+
const midDate = new Date("2026-03-13T09:00:00Z");
|
|
138
|
+
const newDate = new Date("2026-03-14T10:00:00Z");
|
|
139
|
+
|
|
140
|
+
(stat as ReturnType<typeof vi.fn>)
|
|
141
|
+
.mockResolvedValueOnce({ size: 100, mtime: oldDate })
|
|
142
|
+
.mockResolvedValueOnce({ size: 300, mtime: newDate })
|
|
143
|
+
.mockResolvedValueOnce({ size: 200, mtime: midDate });
|
|
144
|
+
|
|
145
|
+
const result = await manager.list("my-volume");
|
|
146
|
+
|
|
147
|
+
expect(result).toHaveLength(3);
|
|
148
|
+
expect(result[0].createdAt).toEqual(newDate);
|
|
149
|
+
expect(result[1].createdAt).toEqual(midDate);
|
|
150
|
+
expect(result[2].createdAt).toEqual(oldDate);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("filters to only matching volume name", async () => {
|
|
154
|
+
(readdir as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
155
|
+
"my-volume-2026-03-14T10-00-00-000Z.tar",
|
|
156
|
+
"other-volume-2026-03-14T10-00-00-000Z.tar",
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const result = await manager.list("my-volume");
|
|
160
|
+
expect(result).toHaveLength(1);
|
|
161
|
+
expect(result[0].volumeName).toBe("my-volume");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns empty array when backup dir does not exist", async () => {
|
|
165
|
+
(readdir as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
166
|
+
|
|
167
|
+
const result = await manager.list("my-volume");
|
|
168
|
+
expect(result).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("delete()", () => {
|
|
173
|
+
it("removes the archive file", async () => {
|
|
174
|
+
await manager.delete("my-volume-2026-03-14T10-00-00-000Z");
|
|
175
|
+
|
|
176
|
+
expect(rm).toHaveBeenCalledWith("/data/fleet/snapshots/my-volume-2026-03-14T10-00-00-000Z.tar", { force: true });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("cleanup()", () => {
|
|
181
|
+
it("removes old snapshots and keeps recent ones", async () => {
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const oldTime = new Date(now - 2 * 60 * 60 * 1000); // 2 hours ago
|
|
184
|
+
const recentTime = new Date(now - 10 * 60 * 1000); // 10 minutes ago
|
|
185
|
+
|
|
186
|
+
(readdir as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
187
|
+
"vol-2026-03-14T08-00-00-000Z.tar",
|
|
188
|
+
"vol-2026-03-14T09-50-00-000Z.tar",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
(stat as ReturnType<typeof vi.fn>)
|
|
192
|
+
.mockResolvedValueOnce({ size: 100, mtime: oldTime })
|
|
193
|
+
.mockResolvedValueOnce({ size: 200, mtime: recentTime });
|
|
194
|
+
|
|
195
|
+
const maxAge = 60 * 60 * 1000; // 1 hour
|
|
196
|
+
const deleted = await manager.cleanup(maxAge);
|
|
197
|
+
|
|
198
|
+
expect(deleted).toBe(1);
|
|
199
|
+
expect(rm).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(rm).toHaveBeenCalledWith("/data/fleet/snapshots/vol-2026-03-14T08-00-00-000Z.tar", { force: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns 0 when backup dir does not exist", async () => {
|
|
204
|
+
(readdir as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
205
|
+
|
|
206
|
+
const result = await manager.cleanup(60_000);
|
|
207
|
+
expect(result).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("skips non-tar files", async () => {
|
|
211
|
+
(readdir as ReturnType<typeof vi.fn>).mockResolvedValue(["readme.txt", ".gitkeep"]);
|
|
212
|
+
|
|
213
|
+
const result = await manager.cleanup(60_000);
|
|
214
|
+
expect(result).toBe(0);
|
|
215
|
+
expect(stat).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
package/src/fleet/index.ts
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { BotProfile } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface IRolloutStrategy {
|
|
4
|
+
/** Select next batch from remaining bots */
|
|
5
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
6
|
+
/** Milliseconds to wait between waves */
|
|
7
|
+
pauseDuration(): number;
|
|
8
|
+
/** What to do when a single bot update fails */
|
|
9
|
+
onBotFailure(botId: string, error: Error, attempt: number): "abort" | "skip" | "retry";
|
|
10
|
+
/** Max retries per bot before skip/abort */
|
|
11
|
+
maxRetries(): number;
|
|
12
|
+
/** Health check timeout per bot (ms) */
|
|
13
|
+
healthCheckTimeout(): number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RollingWaveOptions {
|
|
17
|
+
batchPercent?: number;
|
|
18
|
+
pauseMs?: number;
|
|
19
|
+
maxFailures?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rolling wave strategy — processes bots in configurable percentage batches.
|
|
24
|
+
* Create a new instance per rollout; totalFailures accumulates across waves
|
|
25
|
+
* within a single rollout. Call reset() if reusing across rollouts.
|
|
26
|
+
*/
|
|
27
|
+
export class RollingWaveStrategy implements IRolloutStrategy {
|
|
28
|
+
private readonly batchPercent: number;
|
|
29
|
+
private readonly pauseMs: number;
|
|
30
|
+
private readonly maxFailures: number;
|
|
31
|
+
private totalFailures = 0;
|
|
32
|
+
|
|
33
|
+
constructor(opts: RollingWaveOptions = {}) {
|
|
34
|
+
this.batchPercent = opts.batchPercent ?? 25;
|
|
35
|
+
this.pauseMs = opts.pauseMs ?? 60_000;
|
|
36
|
+
this.maxFailures = opts.maxFailures ?? 3;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
nextBatch(remaining: BotProfile[]): BotProfile[] {
|
|
40
|
+
if (remaining.length === 0) return [];
|
|
41
|
+
const count = Math.max(1, Math.ceil((remaining.length * this.batchPercent) / 100));
|
|
42
|
+
return remaining.slice(0, count);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pauseDuration(): number {
|
|
46
|
+
return this.pauseMs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onBotFailure(_botId: string, _error: Error, attempt: number): "abort" | "skip" | "retry" {
|
|
50
|
+
if (attempt < this.maxRetries()) return "retry";
|
|
51
|
+
this.totalFailures++;
|
|
52
|
+
if (this.totalFailures >= this.maxFailures) return "abort";
|
|
53
|
+
return "skip";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
maxRetries(): number {
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
healthCheckTimeout(): number {
|
|
61
|
+
return 120_000;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reset failure counters for reuse across rollouts. */
|
|
65
|
+
reset(): void {
|
|
66
|
+
this.totalFailures = 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SingleBotStrategy implements IRolloutStrategy {
|
|
71
|
+
nextBatch(remaining: BotProfile[]): BotProfile[] {
|
|
72
|
+
if (remaining.length === 0) return [];
|
|
73
|
+
return remaining.slice(0, 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pauseDuration(): number {
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onBotFailure(_botId: string, _error: Error, attempt: number): "abort" | "skip" | "retry" {
|
|
81
|
+
if (attempt < this.maxRetries()) return "retry";
|
|
82
|
+
return "abort";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
maxRetries(): number {
|
|
86
|
+
return 3;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
healthCheckTimeout(): number {
|
|
90
|
+
return 120_000;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ImmediateStrategy implements IRolloutStrategy {
|
|
95
|
+
nextBatch(remaining: BotProfile[]): BotProfile[] {
|
|
96
|
+
return [...remaining];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pauseDuration(): number {
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onBotFailure(_botId: string, _error: Error, _attempt: number): "abort" | "skip" | "retry" {
|
|
104
|
+
return "skip";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
maxRetries(): number {
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
healthCheckTimeout(): number {
|
|
112
|
+
return 60_000;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createRolloutStrategy(
|
|
117
|
+
type: "rolling-wave" | "single-bot" | "immediate",
|
|
118
|
+
options?: RollingWaveOptions,
|
|
119
|
+
): IRolloutStrategy {
|
|
120
|
+
switch (type) {
|
|
121
|
+
case "rolling-wave":
|
|
122
|
+
return new RollingWaveStrategy(options);
|
|
123
|
+
case "single-bot":
|
|
124
|
+
return new SingleBotStrategy();
|
|
125
|
+
case "immediate":
|
|
126
|
+
return new ImmediateStrategy();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type Docker from "dockerode";
|
|
4
|
+
import { logger } from "../config/logger.js";
|
|
5
|
+
|
|
6
|
+
export interface VolumeSnapshot {
|
|
7
|
+
id: string;
|
|
8
|
+
volumeName: string;
|
|
9
|
+
archivePath: string;
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
sizeBytes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ALPINE_IMAGE = "alpine:latest";
|
|
15
|
+
|
|
16
|
+
/** Strict validation for snapshot IDs — prevents path traversal and shell injection. */
|
|
17
|
+
const SNAPSHOT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
18
|
+
|
|
19
|
+
function validateSnapshotId(snapshotId: string): void {
|
|
20
|
+
if (!SNAPSHOT_ID_RE.test(snapshotId)) {
|
|
21
|
+
throw new Error(`Invalid snapshot ID: ${snapshotId}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Snapshots and restores Docker named volumes using temporary alpine containers.
|
|
27
|
+
* Used for nuclear rollback during fleet updates — if a container update fails,
|
|
28
|
+
* we roll back both the image AND the data volumes.
|
|
29
|
+
*/
|
|
30
|
+
export class VolumeSnapshotManager {
|
|
31
|
+
private readonly docker: Docker;
|
|
32
|
+
private readonly backupDir: string;
|
|
33
|
+
|
|
34
|
+
constructor(docker: Docker, backupDir = "/data/fleet/snapshots") {
|
|
35
|
+
this.docker = docker;
|
|
36
|
+
this.backupDir = backupDir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Create a snapshot of a Docker named volume */
|
|
40
|
+
async snapshot(volumeName: string): Promise<VolumeSnapshot> {
|
|
41
|
+
await mkdir(this.backupDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
44
|
+
const id = `${volumeName}-${timestamp}`;
|
|
45
|
+
const archivePath = join(this.backupDir, `${id}.tar`);
|
|
46
|
+
|
|
47
|
+
const container = await this.docker.createContainer({
|
|
48
|
+
Image: ALPINE_IMAGE,
|
|
49
|
+
Cmd: ["tar", "cf", `/backup/${id}.tar`, "-C", "/source", "."],
|
|
50
|
+
HostConfig: {
|
|
51
|
+
Binds: [`${volumeName}:/source:ro`, `${this.backupDir}:/backup`],
|
|
52
|
+
AutoRemove: true,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await container.start();
|
|
58
|
+
const result = await container.wait();
|
|
59
|
+
if (result.StatusCode !== 0) {
|
|
60
|
+
throw new Error(`Snapshot container exited with code ${result.StatusCode}`);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// AutoRemove handles cleanup, but if start failed the container may still exist
|
|
64
|
+
try {
|
|
65
|
+
await container.remove({ force: true });
|
|
66
|
+
} catch {
|
|
67
|
+
// already removed by AutoRemove
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const info = await stat(archivePath);
|
|
73
|
+
|
|
74
|
+
const snapshot: VolumeSnapshot = {
|
|
75
|
+
id,
|
|
76
|
+
volumeName,
|
|
77
|
+
archivePath,
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
sizeBytes: info.size,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
logger.info(`Volume snapshot created: ${id} (${info.size} bytes)`);
|
|
83
|
+
return snapshot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Restore a volume from a snapshot */
|
|
87
|
+
async restore(snapshotId: string): Promise<void> {
|
|
88
|
+
validateSnapshotId(snapshotId);
|
|
89
|
+
const archivePath = join(this.backupDir, `${snapshotId}.tar`);
|
|
90
|
+
|
|
91
|
+
// Verify archive exists
|
|
92
|
+
await stat(archivePath);
|
|
93
|
+
|
|
94
|
+
// Extract volume name from snapshot ID (everything before the last ISO timestamp)
|
|
95
|
+
const volumeName = this.extractVolumeName(snapshotId);
|
|
96
|
+
|
|
97
|
+
const container = await this.docker.createContainer({
|
|
98
|
+
Image: ALPINE_IMAGE,
|
|
99
|
+
Cmd: ["sh", "-c", `cd /target && rm -rf ./* ./.??* && tar xf /backup/${snapshotId}.tar -C /target`],
|
|
100
|
+
HostConfig: {
|
|
101
|
+
Binds: [`${volumeName}:/target`, `${this.backupDir}:/backup:ro`],
|
|
102
|
+
AutoRemove: true,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await container.start();
|
|
108
|
+
const result = await container.wait();
|
|
109
|
+
if (result.StatusCode !== 0) {
|
|
110
|
+
throw new Error(`Restore container exited with code ${result.StatusCode}`);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
try {
|
|
114
|
+
await container.remove({ force: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// already removed by AutoRemove
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logger.info(`Volume restored from snapshot: ${snapshotId}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** List all snapshots for a volume */
|
|
125
|
+
async list(volumeName: string): Promise<VolumeSnapshot[]> {
|
|
126
|
+
let files: string[];
|
|
127
|
+
try {
|
|
128
|
+
files = await readdir(this.backupDir);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const prefix = `${volumeName}-`;
|
|
134
|
+
const matching = files.filter((f) => f.startsWith(prefix) && f.endsWith(".tar"));
|
|
135
|
+
|
|
136
|
+
const snapshots: VolumeSnapshot[] = [];
|
|
137
|
+
for (const file of matching) {
|
|
138
|
+
const id = file.replace(/\.tar$/, "");
|
|
139
|
+
const archivePath = join(this.backupDir, file);
|
|
140
|
+
try {
|
|
141
|
+
const info = await stat(archivePath);
|
|
142
|
+
snapshots.push({
|
|
143
|
+
id,
|
|
144
|
+
volumeName,
|
|
145
|
+
archivePath,
|
|
146
|
+
createdAt: info.mtime,
|
|
147
|
+
sizeBytes: info.size,
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
// File disappeared between readdir and stat — skip
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort newest first
|
|
155
|
+
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
156
|
+
return snapshots;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Delete a snapshot archive */
|
|
160
|
+
async delete(snapshotId: string): Promise<void> {
|
|
161
|
+
validateSnapshotId(snapshotId);
|
|
162
|
+
const archivePath = join(this.backupDir, `${snapshotId}.tar`);
|
|
163
|
+
await rm(archivePath, { force: true });
|
|
164
|
+
logger.info(`Volume snapshot deleted: ${snapshotId}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Delete all snapshots older than maxAge ms */
|
|
168
|
+
async cleanup(maxAgeMs: number): Promise<number> {
|
|
169
|
+
let files: string[];
|
|
170
|
+
try {
|
|
171
|
+
files = await readdir(this.backupDir);
|
|
172
|
+
} catch {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
177
|
+
let deleted = 0;
|
|
178
|
+
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (!file.endsWith(".tar")) continue;
|
|
181
|
+
const archivePath = join(this.backupDir, file);
|
|
182
|
+
try {
|
|
183
|
+
const info = await stat(archivePath);
|
|
184
|
+
if (info.mtime.getTime() < cutoff) {
|
|
185
|
+
await rm(archivePath, { force: true });
|
|
186
|
+
deleted++;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// File disappeared — skip
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (deleted > 0) {
|
|
194
|
+
logger.info(`Volume snapshot cleanup: removed ${deleted} old snapshots`);
|
|
195
|
+
}
|
|
196
|
+
return deleted;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract volume name from snapshot ID.
|
|
201
|
+
* Snapshot IDs are `${volumeName}-${ISO timestamp with colons/dots replaced}`.
|
|
202
|
+
* ISO timestamps start with 4 digits (year), so we find the last occurrence
|
|
203
|
+
* of `-YYYY` pattern to split.
|
|
204
|
+
*/
|
|
205
|
+
private extractVolumeName(snapshotId: string): string {
|
|
206
|
+
// Match the timestamp part: -YYYY-MM-DDTHH-MM-SS-MMMZ
|
|
207
|
+
const match = snapshotId.match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
|
|
208
|
+
if (!match) {
|
|
209
|
+
throw new Error(`Cannot extract volume name from snapshot ID: ${snapshotId}`);
|
|
210
|
+
}
|
|
211
|
+
return match[1];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -238,7 +238,10 @@ describe("rollbackPluginOnVolume", () => {
|
|
|
238
238
|
});
|
|
239
239
|
|
|
240
240
|
describe("npm package validation", () => {
|
|
241
|
-
const validExec = vi.fn(
|
|
241
|
+
const validExec = vi.fn(
|
|
242
|
+
(_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) =>
|
|
243
|
+
cb(null, "", ""),
|
|
244
|
+
);
|
|
242
245
|
|
|
243
246
|
beforeEach(() => {
|
|
244
247
|
validExec.mockClear();
|
|
@@ -350,7 +353,10 @@ describe("npm package validation", () => {
|
|
|
350
353
|
});
|
|
351
354
|
|
|
352
355
|
it("passes -- separator before package name to prevent flag injection", async () => {
|
|
353
|
-
const execFn = vi.fn(
|
|
356
|
+
const execFn = vi.fn(
|
|
357
|
+
(_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) =>
|
|
358
|
+
cb(null, "", ""),
|
|
359
|
+
);
|
|
354
360
|
await installPluginToVolume({
|
|
355
361
|
pluginId: "p1",
|
|
356
362
|
npmPackage: "lodash",
|