@wopr-network/platform-core 1.18.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.
@@ -0,0 +1,185 @@
1
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { logger } from "../config/logger.js";
4
+ const ALPINE_IMAGE = "alpine:latest";
5
+ /** Strict validation for snapshot IDs — prevents path traversal and shell injection. */
6
+ const SNAPSHOT_ID_RE = /^[A-Za-z0-9._-]+$/;
7
+ function validateSnapshotId(snapshotId) {
8
+ if (!SNAPSHOT_ID_RE.test(snapshotId)) {
9
+ throw new Error(`Invalid snapshot ID: ${snapshotId}`);
10
+ }
11
+ }
12
+ /**
13
+ * Snapshots and restores Docker named volumes using temporary alpine containers.
14
+ * Used for nuclear rollback during fleet updates — if a container update fails,
15
+ * we roll back both the image AND the data volumes.
16
+ */
17
+ export class VolumeSnapshotManager {
18
+ docker;
19
+ backupDir;
20
+ constructor(docker, backupDir = "/data/fleet/snapshots") {
21
+ this.docker = docker;
22
+ this.backupDir = backupDir;
23
+ }
24
+ /** Create a snapshot of a Docker named volume */
25
+ async snapshot(volumeName) {
26
+ await mkdir(this.backupDir, { recursive: true });
27
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
28
+ const id = `${volumeName}-${timestamp}`;
29
+ const archivePath = join(this.backupDir, `${id}.tar`);
30
+ const container = await this.docker.createContainer({
31
+ Image: ALPINE_IMAGE,
32
+ Cmd: ["tar", "cf", `/backup/${id}.tar`, "-C", "/source", "."],
33
+ HostConfig: {
34
+ Binds: [`${volumeName}:/source:ro`, `${this.backupDir}:/backup`],
35
+ AutoRemove: true,
36
+ },
37
+ });
38
+ try {
39
+ await container.start();
40
+ const result = await container.wait();
41
+ if (result.StatusCode !== 0) {
42
+ throw new Error(`Snapshot container exited with code ${result.StatusCode}`);
43
+ }
44
+ }
45
+ catch (err) {
46
+ // AutoRemove handles cleanup, but if start failed the container may still exist
47
+ try {
48
+ await container.remove({ force: true });
49
+ }
50
+ catch {
51
+ // already removed by AutoRemove
52
+ }
53
+ throw err;
54
+ }
55
+ const info = await stat(archivePath);
56
+ const snapshot = {
57
+ id,
58
+ volumeName,
59
+ archivePath,
60
+ createdAt: new Date(),
61
+ sizeBytes: info.size,
62
+ };
63
+ logger.info(`Volume snapshot created: ${id} (${info.size} bytes)`);
64
+ return snapshot;
65
+ }
66
+ /** Restore a volume from a snapshot */
67
+ async restore(snapshotId) {
68
+ validateSnapshotId(snapshotId);
69
+ const archivePath = join(this.backupDir, `${snapshotId}.tar`);
70
+ // Verify archive exists
71
+ await stat(archivePath);
72
+ // Extract volume name from snapshot ID (everything before the last ISO timestamp)
73
+ const volumeName = this.extractVolumeName(snapshotId);
74
+ const container = await this.docker.createContainer({
75
+ Image: ALPINE_IMAGE,
76
+ Cmd: ["sh", "-c", `cd /target && rm -rf ./* ./.??* && tar xf /backup/${snapshotId}.tar -C /target`],
77
+ HostConfig: {
78
+ Binds: [`${volumeName}:/target`, `${this.backupDir}:/backup:ro`],
79
+ AutoRemove: true,
80
+ },
81
+ });
82
+ try {
83
+ await container.start();
84
+ const result = await container.wait();
85
+ if (result.StatusCode !== 0) {
86
+ throw new Error(`Restore container exited with code ${result.StatusCode}`);
87
+ }
88
+ }
89
+ catch (err) {
90
+ try {
91
+ await container.remove({ force: true });
92
+ }
93
+ catch {
94
+ // already removed by AutoRemove
95
+ }
96
+ throw err;
97
+ }
98
+ logger.info(`Volume restored from snapshot: ${snapshotId}`);
99
+ }
100
+ /** List all snapshots for a volume */
101
+ async list(volumeName) {
102
+ let files;
103
+ try {
104
+ files = await readdir(this.backupDir);
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ const prefix = `${volumeName}-`;
110
+ const matching = files.filter((f) => f.startsWith(prefix) && f.endsWith(".tar"));
111
+ const snapshots = [];
112
+ for (const file of matching) {
113
+ const id = file.replace(/\.tar$/, "");
114
+ const archivePath = join(this.backupDir, file);
115
+ try {
116
+ const info = await stat(archivePath);
117
+ snapshots.push({
118
+ id,
119
+ volumeName,
120
+ archivePath,
121
+ createdAt: info.mtime,
122
+ sizeBytes: info.size,
123
+ });
124
+ }
125
+ catch {
126
+ // File disappeared between readdir and stat — skip
127
+ }
128
+ }
129
+ // Sort newest first
130
+ snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
131
+ return snapshots;
132
+ }
133
+ /** Delete a snapshot archive */
134
+ async delete(snapshotId) {
135
+ validateSnapshotId(snapshotId);
136
+ const archivePath = join(this.backupDir, `${snapshotId}.tar`);
137
+ await rm(archivePath, { force: true });
138
+ logger.info(`Volume snapshot deleted: ${snapshotId}`);
139
+ }
140
+ /** Delete all snapshots older than maxAge ms */
141
+ async cleanup(maxAgeMs) {
142
+ let files;
143
+ try {
144
+ files = await readdir(this.backupDir);
145
+ }
146
+ catch {
147
+ return 0;
148
+ }
149
+ const cutoff = Date.now() - maxAgeMs;
150
+ let deleted = 0;
151
+ for (const file of files) {
152
+ if (!file.endsWith(".tar"))
153
+ continue;
154
+ const archivePath = join(this.backupDir, file);
155
+ try {
156
+ const info = await stat(archivePath);
157
+ if (info.mtime.getTime() < cutoff) {
158
+ await rm(archivePath, { force: true });
159
+ deleted++;
160
+ }
161
+ }
162
+ catch {
163
+ // File disappeared — skip
164
+ }
165
+ }
166
+ if (deleted > 0) {
167
+ logger.info(`Volume snapshot cleanup: removed ${deleted} old snapshots`);
168
+ }
169
+ return deleted;
170
+ }
171
+ /**
172
+ * Extract volume name from snapshot ID.
173
+ * Snapshot IDs are `${volumeName}-${ISO timestamp with colons/dots replaced}`.
174
+ * ISO timestamps start with 4 digits (year), so we find the last occurrence
175
+ * of `-YYYY` pattern to split.
176
+ */
177
+ extractVolumeName(snapshotId) {
178
+ // Match the timestamp part: -YYYY-MM-DDTHH-MM-SS-MMMZ
179
+ const match = snapshotId.match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
180
+ if (!match) {
181
+ throw new Error(`Cannot extract volume name from snapshot ID: ${snapshotId}`);
182
+ }
183
+ return match[1];
184
+ }
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -2,7 +2,7 @@ import type { AuditEntry } from "../../admin/audit-log.js";
2
2
 
3
3
  /** Minimal interface for admin audit logging in route factories. */
4
4
  export interface AdminAuditLogger {
5
- log(entry: AuditEntry): void | Promise<unknown>;
5
+ log(entry: AuditEntry): undefined | Promise<unknown>;
6
6
  }
7
7
 
8
8
  /** Safely log an admin audit entry — never throws. */
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ createRolloutStrategy,
5
+ ImmediateStrategy,
6
+ RollingWaveStrategy,
7
+ SingleBotStrategy,
8
+ } from "../rollout-strategy.js";
9
+ import type { BotProfile } from "../types.js";
10
+
11
+ function makeBots(count: number): BotProfile[] {
12
+ return Array.from({ length: count }, (_, i) => ({
13
+ id: randomUUID(),
14
+ tenantId: "tenant-1",
15
+ name: `bot-${i}`,
16
+ description: "",
17
+ image: "ghcr.io/wopr-network/test:latest",
18
+ env: {},
19
+ restartPolicy: "unless-stopped" as const,
20
+ releaseChannel: "stable" as const,
21
+ updatePolicy: "manual" as const,
22
+ }));
23
+ }
24
+
25
+ describe("RollingWaveStrategy", () => {
26
+ it("returns batchPercent of remaining bots", () => {
27
+ const s = new RollingWaveStrategy({ batchPercent: 25 });
28
+ const bots = makeBots(10);
29
+ const batch = s.nextBatch(bots);
30
+ // 25% of 10 = 2.5, ceil = 3
31
+ expect(batch).toHaveLength(3);
32
+ expect(batch).toEqual(bots.slice(0, 3));
33
+ });
34
+
35
+ it("returns minimum 1 bot even with low percentage", () => {
36
+ const s = new RollingWaveStrategy({ batchPercent: 1 });
37
+ const bots = makeBots(2);
38
+ expect(s.nextBatch(bots)).toHaveLength(1);
39
+ });
40
+
41
+ it("returns empty array for empty remaining", () => {
42
+ const s = new RollingWaveStrategy();
43
+ expect(s.nextBatch([])).toHaveLength(0);
44
+ });
45
+
46
+ it("handles 1 bot remaining", () => {
47
+ const s = new RollingWaveStrategy({ batchPercent: 50 });
48
+ const bots = makeBots(1);
49
+ expect(s.nextBatch(bots)).toHaveLength(1);
50
+ });
51
+
52
+ it("handles batchPercent > 100", () => {
53
+ const s = new RollingWaveStrategy({ batchPercent: 200 });
54
+ const bots = makeBots(5);
55
+ // 200% of 5 = 10, ceil = 10, but slice(0,10) on 5 items = 5
56
+ expect(s.nextBatch(bots)).toHaveLength(5);
57
+ });
58
+
59
+ it("returns correct pause duration", () => {
60
+ expect(new RollingWaveStrategy().pauseDuration()).toBe(60_000);
61
+ expect(new RollingWaveStrategy({ pauseMs: 30_000 }).pauseDuration()).toBe(30_000);
62
+ });
63
+
64
+ it("retries on failure until maxRetries", () => {
65
+ const s = new RollingWaveStrategy({ maxFailures: 2 });
66
+ const err = new Error("fail");
67
+ expect(s.onBotFailure("b1", err, 0)).toBe("retry");
68
+ expect(s.onBotFailure("b1", err, 1)).toBe("retry");
69
+ });
70
+
71
+ it("skips after maxRetries when under maxFailures", () => {
72
+ const s = new RollingWaveStrategy({ maxFailures: 3 });
73
+ const err = new Error("fail");
74
+ // attempt >= maxRetries (2), first total failure
75
+ expect(s.onBotFailure("b1", err, 2)).toBe("skip");
76
+ });
77
+
78
+ it("aborts when total failures reach maxFailures", () => {
79
+ const s = new RollingWaveStrategy({ maxFailures: 2 });
80
+ const err = new Error("fail");
81
+ // exhaust retries for bot1 → skip (totalFailures=1)
82
+ expect(s.onBotFailure("b1", err, 2)).toBe("skip");
83
+ // exhaust retries for bot2 → abort (totalFailures=2 >= maxFailures=2)
84
+ expect(s.onBotFailure("b2", err, 2)).toBe("abort");
85
+ });
86
+
87
+ it("has maxRetries of 2", () => {
88
+ expect(new RollingWaveStrategy().maxRetries()).toBe(2);
89
+ });
90
+
91
+ it("has healthCheckTimeout of 120_000", () => {
92
+ expect(new RollingWaveStrategy().healthCheckTimeout()).toBe(120_000);
93
+ });
94
+ });
95
+
96
+ describe("SingleBotStrategy", () => {
97
+ it("returns exactly 1 bot", () => {
98
+ const s = new SingleBotStrategy();
99
+ const bots = makeBots(10);
100
+ expect(s.nextBatch(bots)).toHaveLength(1);
101
+ expect(s.nextBatch(bots)[0]).toBe(bots[0]);
102
+ });
103
+
104
+ it("returns empty for empty remaining", () => {
105
+ expect(new SingleBotStrategy().nextBatch([])).toHaveLength(0);
106
+ });
107
+
108
+ it("has pauseDuration of 0", () => {
109
+ expect(new SingleBotStrategy().pauseDuration()).toBe(0);
110
+ });
111
+
112
+ it("always retries on failure", () => {
113
+ const s = new SingleBotStrategy();
114
+ const err = new Error("fail");
115
+ expect(s.onBotFailure("b1", err, 0)).toBe("retry");
116
+ expect(s.onBotFailure("b1", err, 1)).toBe("retry");
117
+ expect(s.onBotFailure("b1", err, 2)).toBe("retry");
118
+ // After maxRetries (3), aborts instead of retrying forever
119
+ expect(s.onBotFailure("b1", err, 99)).toBe("abort");
120
+ });
121
+
122
+ it("has maxRetries of 3", () => {
123
+ expect(new SingleBotStrategy().maxRetries()).toBe(3);
124
+ });
125
+
126
+ it("has healthCheckTimeout of 120_000", () => {
127
+ expect(new SingleBotStrategy().healthCheckTimeout()).toBe(120_000);
128
+ });
129
+ });
130
+
131
+ describe("ImmediateStrategy", () => {
132
+ it("returns all remaining bots", () => {
133
+ const s = new ImmediateStrategy();
134
+ const bots = makeBots(10);
135
+ expect(s.nextBatch(bots)).toHaveLength(10);
136
+ expect(s.nextBatch(bots)).toEqual(bots);
137
+ });
138
+
139
+ it("returns empty for empty remaining", () => {
140
+ expect(new ImmediateStrategy().nextBatch([])).toHaveLength(0);
141
+ });
142
+
143
+ it("does not mutate the input array", () => {
144
+ const s = new ImmediateStrategy();
145
+ const bots = makeBots(3);
146
+ const result = s.nextBatch(bots);
147
+ expect(result).not.toBe(bots);
148
+ expect(result).toEqual(bots);
149
+ });
150
+
151
+ it("has pauseDuration of 0", () => {
152
+ expect(new ImmediateStrategy().pauseDuration()).toBe(0);
153
+ });
154
+
155
+ it("always skips on failure", () => {
156
+ const s = new ImmediateStrategy();
157
+ const err = new Error("fail");
158
+ expect(s.onBotFailure("b1", err, 0)).toBe("skip");
159
+ expect(s.onBotFailure("b1", err, 5)).toBe("skip");
160
+ });
161
+
162
+ it("has maxRetries of 1", () => {
163
+ expect(new ImmediateStrategy().maxRetries()).toBe(1);
164
+ });
165
+
166
+ it("has healthCheckTimeout of 60_000", () => {
167
+ expect(new ImmediateStrategy().healthCheckTimeout()).toBe(60_000);
168
+ });
169
+ });
170
+
171
+ describe("createRolloutStrategy", () => {
172
+ it("creates RollingWaveStrategy", () => {
173
+ const s = createRolloutStrategy("rolling-wave");
174
+ expect(s).toBeInstanceOf(RollingWaveStrategy);
175
+ });
176
+
177
+ it("creates RollingWaveStrategy with options", () => {
178
+ const s = createRolloutStrategy("rolling-wave", { batchPercent: 50, pauseMs: 10_000 });
179
+ expect(s).toBeInstanceOf(RollingWaveStrategy);
180
+ expect(s.pauseDuration()).toBe(10_000);
181
+ });
182
+
183
+ it("creates SingleBotStrategy", () => {
184
+ const s = createRolloutStrategy("single-bot");
185
+ expect(s).toBeInstanceOf(SingleBotStrategy);
186
+ });
187
+
188
+ it("creates ImmediateStrategy", () => {
189
+ const s = createRolloutStrategy("immediate");
190
+ expect(s).toBeInstanceOf(ImmediateStrategy);
191
+ });
192
+ });
@@ -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
+ });
@@ -1,3 +1,5 @@
1
1
  export * from "./repository-types.js";
2
+ export * from "./rollout-strategy.js";
2
3
  export * from "./services.js";
3
4
  export * from "./types.js";
5
+ export * from "./volume-snapshot-manager.js";