@mycadet/cli 0.12.11 → 0.13.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/README.md +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +3 -2
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +182 -23
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +6 -6
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +3 -3
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +1 -1
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/upgrade.ts +78 -156
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +0 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +134 -18
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
symlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import * as tar from "tar";
|
|
18
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
19
|
+
import {
|
|
20
|
+
createRootBackup,
|
|
21
|
+
initializeRootArchive,
|
|
22
|
+
DockerRootStorage,
|
|
23
|
+
LocalRootStorage,
|
|
24
|
+
packRoot,
|
|
25
|
+
restoreRootBackup,
|
|
26
|
+
stageRootArchive,
|
|
27
|
+
STATE_ROOTS,
|
|
28
|
+
validateRootArchive,
|
|
29
|
+
type RootBackupOps,
|
|
30
|
+
} from "../lib/root-backup.js";
|
|
31
|
+
|
|
32
|
+
const scratch: string[] = [];
|
|
33
|
+
async function fixture() {
|
|
34
|
+
const dir = await mkdtemp(join(tmpdir(), "root-backup-test-"));
|
|
35
|
+
scratch.push(dir);
|
|
36
|
+
const root = join(dir, "instance");
|
|
37
|
+
for (const name of STATE_ROOTS)
|
|
38
|
+
await mkdir(join(root, name), { recursive: true, mode: 0o700 });
|
|
39
|
+
await mkdir(join(root, "workspace", "data", "db"), { recursive: true });
|
|
40
|
+
await writeFile(join(root, "workspace", "notes.md"), "source notes");
|
|
41
|
+
await writeFile(join(root, "workspace", "cadet.pid"), "99999999");
|
|
42
|
+
await mkdir(join(root, "workspace", "signals"));
|
|
43
|
+
await writeFile(join(root, "workspace", "signals", "old"), "transient");
|
|
44
|
+
await writeFile(
|
|
45
|
+
join(root, "gateway-security", "guardian-init.lock"),
|
|
46
|
+
"persistent lock",
|
|
47
|
+
);
|
|
48
|
+
await writeFile(
|
|
49
|
+
join(root, "gateway-security", "actor-token-signing-key"),
|
|
50
|
+
Buffer.alloc(32, 7),
|
|
51
|
+
{ mode: 0o600 },
|
|
52
|
+
);
|
|
53
|
+
// Deliberately not valid credential JSON: capture must not decode/decrypt it.
|
|
54
|
+
await writeFile(
|
|
55
|
+
join(root, "ces-security", "keys.enc"),
|
|
56
|
+
"opaque encrypted bytes",
|
|
57
|
+
{ mode: 0o600 },
|
|
58
|
+
);
|
|
59
|
+
await writeFile(
|
|
60
|
+
join(root, "ces-security", "store.key"),
|
|
61
|
+
Buffer.alloc(32, 3),
|
|
62
|
+
{ mode: 0o600 },
|
|
63
|
+
);
|
|
64
|
+
await writeFile(join(root, "ces-data", "grants.json"), "CES policies");
|
|
65
|
+
await symlink("notes.md", join(root, "workspace", "notes-link"));
|
|
66
|
+
const entry: AssistantEntry = {
|
|
67
|
+
assistantId: "test",
|
|
68
|
+
cloud: "local",
|
|
69
|
+
runtimeUrl: "http://127.0.0.1:7831",
|
|
70
|
+
resources: {
|
|
71
|
+
instanceDir: root,
|
|
72
|
+
daemonPort: 7821,
|
|
73
|
+
gatewayPort: 7831,
|
|
74
|
+
cesPort: 7790,
|
|
75
|
+
qdrantPort: 6334,
|
|
76
|
+
signingKey: "07".repeat(32),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
return { dir, root, entry, file: join(dir, "backup.tar.gz") };
|
|
80
|
+
}
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
for (const dir of scratch.splice(0))
|
|
83
|
+
await rm(dir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("root tar backup", () => {
|
|
87
|
+
test("initializes a fresh stopped instance with complete roots and the archived signing key", async () => {
|
|
88
|
+
const { root, entry, file, dir } = await fixture();
|
|
89
|
+
await packRoot(root, file);
|
|
90
|
+
const destination = join(dir, "fresh-instance");
|
|
91
|
+
await mkdir(destination);
|
|
92
|
+
const fresh = {
|
|
93
|
+
...entry,
|
|
94
|
+
resources: { ...entry.resources!, instanceDir: destination },
|
|
95
|
+
};
|
|
96
|
+
const key = await initializeRootArchive(fresh, file);
|
|
97
|
+
expect(key).toBe("07".repeat(32));
|
|
98
|
+
expect(
|
|
99
|
+
await readFile(join(destination, "ces-security", "keys.enc"), "utf8"),
|
|
100
|
+
).toBe("opaque encrypted bytes");
|
|
101
|
+
expect(
|
|
102
|
+
await readFile(join(destination, "ces-security", "store.key")),
|
|
103
|
+
).toEqual(Buffer.alloc(32, 3));
|
|
104
|
+
expect(
|
|
105
|
+
await readFile(join(destination, "ces-data", "grants.json"), "utf8"),
|
|
106
|
+
).toBe("CES policies");
|
|
107
|
+
expect(await readdir(destination)).toEqual([...STATE_ROOTS].sort());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("initialization refuses running services before replacing any roots", async () => {
|
|
111
|
+
const { root, entry, file } = await fixture();
|
|
112
|
+
await packRoot(root, file);
|
|
113
|
+
const storage = new LocalRootStorage(entry);
|
|
114
|
+
storage.running = async () => true;
|
|
115
|
+
await expect(initializeRootArchive(entry, file, storage)).rejects.toThrow(
|
|
116
|
+
"still running",
|
|
117
|
+
);
|
|
118
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
119
|
+
"source notes",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("round-trips every service root, encrypted bytes, permissions and internal links", async () => {
|
|
124
|
+
const { root, entry, file, dir } = await fixture();
|
|
125
|
+
await createRootBackup(entry, file);
|
|
126
|
+
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
|
127
|
+
const staged = await stageRootArchive(file, dir);
|
|
128
|
+
const restored = join(staged, "state");
|
|
129
|
+
expect(
|
|
130
|
+
await readFile(join(restored, "ces-security", "keys.enc"), "utf8"),
|
|
131
|
+
).toBe("opaque encrypted bytes");
|
|
132
|
+
expect(
|
|
133
|
+
await readFile(join(restored, "ces-data", "grants.json"), "utf8"),
|
|
134
|
+
).toBe("CES policies");
|
|
135
|
+
expect(
|
|
136
|
+
await readFile(
|
|
137
|
+
join(restored, "gateway-security", "guardian-init.lock"),
|
|
138
|
+
"utf8",
|
|
139
|
+
),
|
|
140
|
+
).toBe("persistent lock");
|
|
141
|
+
expect(
|
|
142
|
+
await readFile(join(restored, "workspace", "notes-link"), "utf8"),
|
|
143
|
+
).toBe("source notes");
|
|
144
|
+
expect(
|
|
145
|
+
(await lstat(join(restored, "workspace", "notes-link"))).isSymbolicLink(),
|
|
146
|
+
).toBe(true);
|
|
147
|
+
expect(
|
|
148
|
+
(await stat(join(restored, "ces-security", "store.key"))).mode & 0o777,
|
|
149
|
+
).toBe(0o600);
|
|
150
|
+
expect(await readdir(join(restored, "workspace"))).not.toContain(
|
|
151
|
+
"cadet.pid",
|
|
152
|
+
);
|
|
153
|
+
expect(await readdir(join(restored, "workspace"))).not.toContain("signals");
|
|
154
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
155
|
+
"source notes",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("captures committed SQLite WAL frames without opening or checkpointing the live DB", async () => {
|
|
160
|
+
const { entry, root, file, dir } = await fixture();
|
|
161
|
+
const db = new Database(
|
|
162
|
+
join(root, "workspace", "data", "db", "assistant.db"),
|
|
163
|
+
);
|
|
164
|
+
db.exec(
|
|
165
|
+
"PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE notes (text); INSERT INTO notes VALUES ('committed WAL');",
|
|
166
|
+
);
|
|
167
|
+
try {
|
|
168
|
+
await createRootBackup(entry, file);
|
|
169
|
+
const staged = await stageRootArchive(file, dir);
|
|
170
|
+
const copy = new Database(
|
|
171
|
+
join(staged, "state", "workspace", "data", "db", "assistant.db"),
|
|
172
|
+
);
|
|
173
|
+
try {
|
|
174
|
+
expect(copy.query("SELECT text FROM notes").get()).toEqual({
|
|
175
|
+
text: "committed WAL",
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
copy.close();
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
db.close();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("resumes services before compression, but never captures after failed graceful shutdown", async () => {
|
|
186
|
+
const { entry, file, root } = await fixture();
|
|
187
|
+
let running = true;
|
|
188
|
+
const events: string[] = [];
|
|
189
|
+
const storage = new LocalRootStorage(entry);
|
|
190
|
+
const ops: RootBackupOps = {
|
|
191
|
+
storage: {
|
|
192
|
+
running: async () => running,
|
|
193
|
+
capture: async (path) => {
|
|
194
|
+
events.push("capture");
|
|
195
|
+
expect(running).toBe(false);
|
|
196
|
+
await storage.capture(path);
|
|
197
|
+
},
|
|
198
|
+
replace: (path) => storage.replace(path),
|
|
199
|
+
},
|
|
200
|
+
sleep: async () => {
|
|
201
|
+
events.push("sleep");
|
|
202
|
+
running = false;
|
|
203
|
+
},
|
|
204
|
+
wake: async () => {
|
|
205
|
+
events.push("wake");
|
|
206
|
+
running = true;
|
|
207
|
+
expect(
|
|
208
|
+
await readFile(join(root, "workspace", "notes.md"), "utf8"),
|
|
209
|
+
).toBe("source notes");
|
|
210
|
+
},
|
|
211
|
+
save: () => {},
|
|
212
|
+
};
|
|
213
|
+
await createRootBackup(entry, file, ops);
|
|
214
|
+
expect(events).toEqual(["sleep", "capture", "wake"]);
|
|
215
|
+
events.length = 0;
|
|
216
|
+
ops.sleep = async () => {
|
|
217
|
+
events.push("sleep");
|
|
218
|
+
throw new Error("refused shutdown");
|
|
219
|
+
};
|
|
220
|
+
await expect(createRootBackup(entry, file, ops)).rejects.toThrow(
|
|
221
|
+
"refused shutdown",
|
|
222
|
+
);
|
|
223
|
+
expect(events).toEqual(["sleep", "wake"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("restore replaces stale state and retains a restorable recovery archive", async () => {
|
|
227
|
+
const { entry, root, file, dir } = await fixture();
|
|
228
|
+
await createRootBackup(entry, file);
|
|
229
|
+
await writeFile(join(root, "workspace", "notes.md"), "newer destination");
|
|
230
|
+
await writeFile(join(root, "ces-data", "stale.json"), "remove me");
|
|
231
|
+
const recovery = join(dir, "recovery.tar.gz");
|
|
232
|
+
const storage = new LocalRootStorage(entry);
|
|
233
|
+
const ops: RootBackupOps = {
|
|
234
|
+
storage,
|
|
235
|
+
sleep: async () => {
|
|
236
|
+
throw new Error("already stopped");
|
|
237
|
+
},
|
|
238
|
+
wake: async () => {
|
|
239
|
+
throw new Error("must stay stopped");
|
|
240
|
+
},
|
|
241
|
+
save: () => {},
|
|
242
|
+
};
|
|
243
|
+
expect(
|
|
244
|
+
await restoreRootBackup(entry, file, { recoveryPath: recovery }, ops),
|
|
245
|
+
).toBe(recovery);
|
|
246
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
247
|
+
"source notes",
|
|
248
|
+
);
|
|
249
|
+
expect(await readdir(join(root, "ces-data"))).not.toContain("stale.json");
|
|
250
|
+
const previous = await stageRootArchive(recovery, dir);
|
|
251
|
+
expect(
|
|
252
|
+
await readFile(join(previous, "state", "workspace", "notes.md"), "utf8"),
|
|
253
|
+
).toBe("newer destination");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("dry-run leaves services and destination untouched", async () => {
|
|
257
|
+
const { entry, file, root } = await fixture();
|
|
258
|
+
await createRootBackup(entry, file);
|
|
259
|
+
await writeFile(join(root, "workspace", "notes.md"), "destination");
|
|
260
|
+
const ops: RootBackupOps = {
|
|
261
|
+
storage: new LocalRootStorage(entry),
|
|
262
|
+
sleep: async () => {
|
|
263
|
+
throw new Error("must not stop");
|
|
264
|
+
},
|
|
265
|
+
wake: async () => {},
|
|
266
|
+
save: () => {
|
|
267
|
+
throw new Error("must not save");
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
await restoreRootBackup(entry, file, { dryRun: true }, ops);
|
|
271
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
272
|
+
"destination",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a failed replacement recovers every service root before resuming", async () => {
|
|
277
|
+
const { entry, file, root, dir } = await fixture();
|
|
278
|
+
await createRootBackup(entry, file);
|
|
279
|
+
await writeFile(join(root, "workspace", "notes.md"), "destination");
|
|
280
|
+
const storage = new LocalRootStorage(entry);
|
|
281
|
+
let attempts = 0,
|
|
282
|
+
running = true;
|
|
283
|
+
const ops: RootBackupOps = {
|
|
284
|
+
storage: {
|
|
285
|
+
running: async () => running,
|
|
286
|
+
capture: (path) => storage.capture(path),
|
|
287
|
+
replace: async (path) => {
|
|
288
|
+
attempts++;
|
|
289
|
+
await storage.replace(path);
|
|
290
|
+
if (attempts === 1) throw new Error("partial restore failure");
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
sleep: async () => {
|
|
294
|
+
running = false;
|
|
295
|
+
},
|
|
296
|
+
wake: async () => {
|
|
297
|
+
running = true;
|
|
298
|
+
},
|
|
299
|
+
save: () => {},
|
|
300
|
+
};
|
|
301
|
+
await expect(
|
|
302
|
+
restoreRootBackup(
|
|
303
|
+
entry,
|
|
304
|
+
file,
|
|
305
|
+
{ recoveryPath: join(dir, "recovery.tar.gz") },
|
|
306
|
+
ops,
|
|
307
|
+
),
|
|
308
|
+
).rejects.toThrow("partial restore failure");
|
|
309
|
+
expect(attempts).toBe(2);
|
|
310
|
+
expect(running).toBe(true);
|
|
311
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
312
|
+
"destination",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("rejects traversal, unsafe links, missing roots, legacy bundles and truncated gzip before stopping", async () => {
|
|
317
|
+
const { root, file, dir } = await fixture();
|
|
318
|
+
await symlink("../../escape", join(root, "workspace", "escape"));
|
|
319
|
+
await tar.c({ cwd: root, file, gzip: true }, [...STATE_ROOTS]);
|
|
320
|
+
await expect(validateRootArchive(file)).rejects.toThrow(
|
|
321
|
+
"Invalid root archive path",
|
|
322
|
+
);
|
|
323
|
+
await rm(join(root, "workspace", "escape"));
|
|
324
|
+
await tar.c({ cwd: root, file, gzip: true }, ["workspace"]);
|
|
325
|
+
await expect(validateRootArchive(file)).rejects.toThrow(
|
|
326
|
+
"missing state root",
|
|
327
|
+
);
|
|
328
|
+
await packRoot(root, file);
|
|
329
|
+
const compressed = await readFile(file);
|
|
330
|
+
await writeFile(file, compressed.subarray(0, compressed.length - 70));
|
|
331
|
+
await expect(stageRootArchive(file, dir)).rejects.toThrow();
|
|
332
|
+
await expect(
|
|
333
|
+
stageRootArchive(join(dir, "legacy.vbundle"), dir),
|
|
334
|
+
).rejects.toThrow("vellum-to-cadet");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test.skipIf(process.platform === "win32")(
|
|
338
|
+
"unreadable credential files fail capture without publishing an incomplete archive",
|
|
339
|
+
async () => {
|
|
340
|
+
const { entry, file, root } = await fixture();
|
|
341
|
+
const keys = join(root, "ces-security", "keys.enc");
|
|
342
|
+
await chmod(keys, 0);
|
|
343
|
+
try {
|
|
344
|
+
await expect(createRootBackup(entry, file)).rejects.toThrow();
|
|
345
|
+
await expect(stat(file)).rejects.toThrow();
|
|
346
|
+
} finally {
|
|
347
|
+
await chmod(keys, 0o600);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
test("Docker restore uses inspected server mounts, streams bytes without host binds, and assigns destination ownership", async () => {
|
|
353
|
+
const { entry, root } = await fixture();
|
|
354
|
+
entry.cloud = "docker";
|
|
355
|
+
const calls: Array<{ args: string[]; source: string }> = [];
|
|
356
|
+
const storage = new DockerRootStorage(entry, {
|
|
357
|
+
output: async (_command, _args) =>
|
|
358
|
+
JSON.stringify([
|
|
359
|
+
{
|
|
360
|
+
Image: "sha256:existing-image",
|
|
361
|
+
State: { Running: false, Status: "exited" },
|
|
362
|
+
Config: { Env: [], User: "1001:1001" },
|
|
363
|
+
Mounts: STATE_ROOTS.map((name) => ({
|
|
364
|
+
Type: "volume",
|
|
365
|
+
Name: `remote-${name}`,
|
|
366
|
+
Source: `/remote/${name}`,
|
|
367
|
+
Destination: `/${name}`,
|
|
368
|
+
RW: true,
|
|
369
|
+
})),
|
|
370
|
+
},
|
|
371
|
+
]),
|
|
372
|
+
copy: async () => {},
|
|
373
|
+
install: async (args, source) => {
|
|
374
|
+
calls.push({ args, source });
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
await storage.replace(root);
|
|
378
|
+
expect(calls).toHaveLength(4);
|
|
379
|
+
for (const { args, source } of calls) {
|
|
380
|
+
expect(args).toContain("--network=none");
|
|
381
|
+
expect(args).toContain("1001:1001");
|
|
382
|
+
expect(args.join(" ")).not.toContain("type=bind");
|
|
383
|
+
expect(source.startsWith(root)).toBe(true);
|
|
384
|
+
}
|
|
385
|
+
expect(
|
|
386
|
+
calls.some(({ args }) =>
|
|
387
|
+
args.includes("type=volume,src=remote-ces-data,dst=/restore"),
|
|
388
|
+
),
|
|
389
|
+
).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
test("Docker signing-key conflicts fail preflight before shutdown or writes", async () => {
|
|
392
|
+
const { entry, root, file } = await fixture();
|
|
393
|
+
await packRoot(root, file);
|
|
394
|
+
entry.cloud = "docker";
|
|
395
|
+
let sleeps = 0,
|
|
396
|
+
writes = 0;
|
|
397
|
+
const storage = new DockerRootStorage(entry, {
|
|
398
|
+
output: async () =>
|
|
399
|
+
JSON.stringify([
|
|
400
|
+
{
|
|
401
|
+
Image: "sha256:existing-image",
|
|
402
|
+
State: { Running: true, Status: "running" },
|
|
403
|
+
Config: {
|
|
404
|
+
Env: ["ACTOR_TOKEN_SIGNING_KEY=" + "09".repeat(32)],
|
|
405
|
+
User: "1001:1001",
|
|
406
|
+
},
|
|
407
|
+
Mounts: [],
|
|
408
|
+
},
|
|
409
|
+
]),
|
|
410
|
+
copy: async () => {},
|
|
411
|
+
install: async () => {
|
|
412
|
+
writes++;
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
const ops: RootBackupOps = {
|
|
416
|
+
storage,
|
|
417
|
+
sleep: async () => {
|
|
418
|
+
sleeps++;
|
|
419
|
+
},
|
|
420
|
+
wake: async () => {},
|
|
421
|
+
save: () => {},
|
|
422
|
+
};
|
|
423
|
+
await expect(restoreRootBackup(entry, file, {}, ops)).rejects.toThrow(
|
|
424
|
+
"different signing key",
|
|
425
|
+
);
|
|
426
|
+
expect(sleeps).toBe(0);
|
|
427
|
+
expect(writes).toBe(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("interrupted startup leaves restored services stopped and keeps the recovery archive", async () => {
|
|
431
|
+
const { entry, root, file, dir } = await fixture();
|
|
432
|
+
await createRootBackup(entry, file);
|
|
433
|
+
const storage = new LocalRootStorage(entry);
|
|
434
|
+
let running = true,
|
|
435
|
+
sleeps = 0;
|
|
436
|
+
const ops: RootBackupOps = {
|
|
437
|
+
storage: {
|
|
438
|
+
running: async () => running,
|
|
439
|
+
capture: (path) => storage.capture(path),
|
|
440
|
+
replace: (path) => storage.replace(path),
|
|
441
|
+
},
|
|
442
|
+
sleep: async () => {
|
|
443
|
+
running = false;
|
|
444
|
+
sleeps++;
|
|
445
|
+
},
|
|
446
|
+
wake: async () => {
|
|
447
|
+
running = true;
|
|
448
|
+
throw new Error("failed startup");
|
|
449
|
+
},
|
|
450
|
+
save: () => {},
|
|
451
|
+
};
|
|
452
|
+
const recovery = join(dir, "recovery.tar.gz");
|
|
453
|
+
await expect(
|
|
454
|
+
restoreRootBackup(entry, file, { recoveryPath: recovery }, ops),
|
|
455
|
+
).rejects.toThrow("services were stopped");
|
|
456
|
+
expect(running).toBe(false);
|
|
457
|
+
expect(sleeps).toBe(2);
|
|
458
|
+
await validateRootArchive(recovery);
|
|
459
|
+
expect(await readFile(join(root, "workspace", "notes.md"), "utf8")).toBe(
|
|
460
|
+
"source notes",
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
test("concurrent captures of one installation are serialized by an operation lock", async () => {
|
|
464
|
+
const { entry, file, dir } = await fixture();
|
|
465
|
+
const storage = new LocalRootStorage(entry);
|
|
466
|
+
let release!: () => void;
|
|
467
|
+
let started!: () => void;
|
|
468
|
+
const waiting = new Promise<void>((done) => {
|
|
469
|
+
release = done;
|
|
470
|
+
});
|
|
471
|
+
const capturing = new Promise<void>((done) => {
|
|
472
|
+
started = done;
|
|
473
|
+
});
|
|
474
|
+
const ops: RootBackupOps = {
|
|
475
|
+
storage: {
|
|
476
|
+
running: async () => false,
|
|
477
|
+
capture: async (path) => {
|
|
478
|
+
started();
|
|
479
|
+
await waiting;
|
|
480
|
+
await storage.capture(path);
|
|
481
|
+
},
|
|
482
|
+
replace: (path) => storage.replace(path),
|
|
483
|
+
},
|
|
484
|
+
sleep: async () => {},
|
|
485
|
+
wake: async () => {},
|
|
486
|
+
save: () => {},
|
|
487
|
+
};
|
|
488
|
+
const first = createRootBackup(entry, file, ops);
|
|
489
|
+
await capturing;
|
|
490
|
+
try {
|
|
491
|
+
await expect(
|
|
492
|
+
createRootBackup(entry, join(dir, "second.tar.gz"), ops),
|
|
493
|
+
).rejects.toThrow("Another backup/restore");
|
|
494
|
+
} finally {
|
|
495
|
+
release();
|
|
496
|
+
await first;
|
|
497
|
+
}
|
|
498
|
+
await validateRootArchive(file);
|
|
499
|
+
});
|
|
500
|
+
test("deferred recovery restore keeps services stopped so its caller can select the runtime", async () => {
|
|
501
|
+
const { entry, file, dir } = await fixture();
|
|
502
|
+
await createRootBackup(entry, file);
|
|
503
|
+
const storage = new LocalRootStorage(entry);
|
|
504
|
+
let running = true;
|
|
505
|
+
const ops: RootBackupOps = {
|
|
506
|
+
storage: {
|
|
507
|
+
running: async () => running,
|
|
508
|
+
capture: (path) => storage.capture(path),
|
|
509
|
+
replace: (path) => storage.replace(path),
|
|
510
|
+
},
|
|
511
|
+
sleep: async () => {
|
|
512
|
+
running = false;
|
|
513
|
+
},
|
|
514
|
+
wake: async () => {
|
|
515
|
+
throw new Error("caller owns restart");
|
|
516
|
+
},
|
|
517
|
+
save: () => {},
|
|
518
|
+
};
|
|
519
|
+
await restoreRootBackup(
|
|
520
|
+
entry,
|
|
521
|
+
file,
|
|
522
|
+
{ recoveryPath: join(dir, "recovery.tar.gz"), resume: false },
|
|
523
|
+
ops,
|
|
524
|
+
);
|
|
525
|
+
expect(running).toBe(false);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import * as config from "../lib/assistant-config.js";
|
|
6
|
+
import * as roots from "../lib/root-backup.js";
|
|
7
|
+
import * as cloud from "../lib/cloud-client.js";
|
|
8
|
+
import * as lifecycle from "../lib/upgrade-lifecycle.js";
|
|
9
|
+
import * as guardian from "../lib/guardian-token.js";
|
|
10
|
+
import { restore } from "../commands/restore.js";
|
|
11
|
+
|
|
12
|
+
const directory = await mkdtemp(join(tmpdir(), "root-restore-command-"));
|
|
13
|
+
const file = join(directory, "backup.tar.gz");
|
|
14
|
+
const source = join(directory, "source");
|
|
15
|
+
for (const root of roots.STATE_ROOTS)
|
|
16
|
+
await mkdir(join(source, root), { recursive: true });
|
|
17
|
+
await writeFile(
|
|
18
|
+
join(source, "gateway-security", "actor-token-signing-key"),
|
|
19
|
+
Buffer.alloc(32, 1),
|
|
20
|
+
);
|
|
21
|
+
await roots.packRoot(source, file);
|
|
22
|
+
const argv = [...process.argv];
|
|
23
|
+
const lookup = spyOn(config, "findAssistantByName");
|
|
24
|
+
const versionRestore = spyOn(lifecycle, "performDockerRollback");
|
|
25
|
+
const hostRestore = spyOn(roots, "restoreRootBackup");
|
|
26
|
+
const token = spyOn(cloud, "readCloudToken");
|
|
27
|
+
const signedUrl = spyOn(cloud, "cloudRequestSignedUrl");
|
|
28
|
+
const upload = spyOn(cloud, "platformUploadToSignedUrl");
|
|
29
|
+
const importCloud = spyOn(cloud, "platformImportBundleFromGcs");
|
|
30
|
+
const guardianRead = spyOn(guardian, "loadGuardianToken");
|
|
31
|
+
const guardianLease = spyOn(guardian, "leaseGuardianToken");
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
for (const spy of [
|
|
35
|
+
lookup,
|
|
36
|
+
hostRestore,
|
|
37
|
+
versionRestore,
|
|
38
|
+
token,
|
|
39
|
+
signedUrl,
|
|
40
|
+
upload,
|
|
41
|
+
importCloud,
|
|
42
|
+
guardianRead,
|
|
43
|
+
guardianLease,
|
|
44
|
+
])
|
|
45
|
+
spy.mockReset();
|
|
46
|
+
versionRestore.mockResolvedValue(undefined);
|
|
47
|
+
hostRestore.mockResolvedValue(join(directory, "recovery.tar.gz"));
|
|
48
|
+
token.mockReturnValue("session-token");
|
|
49
|
+
signedUrl.mockResolvedValue({
|
|
50
|
+
url: "https://storage.googleapis.com/upload",
|
|
51
|
+
bundleKey: "root-archive",
|
|
52
|
+
expiresAt: "2026-10-15T00:00:00Z",
|
|
53
|
+
});
|
|
54
|
+
upload.mockResolvedValue(undefined);
|
|
55
|
+
importCloud.mockResolvedValue({ statusCode: 200, body: { success: true } });
|
|
56
|
+
guardianRead.mockImplementation(() => {
|
|
57
|
+
throw new Error("must not read guardian tokens");
|
|
58
|
+
});
|
|
59
|
+
guardianLease.mockImplementation(async () => {
|
|
60
|
+
throw new Error("must not lease guardian tokens");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
process.argv = [...argv];
|
|
65
|
+
});
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
for (const spy of [
|
|
68
|
+
lookup,
|
|
69
|
+
hostRestore,
|
|
70
|
+
versionRestore,
|
|
71
|
+
token,
|
|
72
|
+
signedUrl,
|
|
73
|
+
upload,
|
|
74
|
+
importCloud,
|
|
75
|
+
guardianRead,
|
|
76
|
+
guardianLease,
|
|
77
|
+
])
|
|
78
|
+
spy.mockRestore();
|
|
79
|
+
await rm(directory, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
for (const topology of ["local", "docker"] as const) {
|
|
83
|
+
test(`${topology} restore uses root storage without runtime authentication`, async () => {
|
|
84
|
+
const entry: config.AssistantEntry = {
|
|
85
|
+
assistantId: "test",
|
|
86
|
+
cloud: topology,
|
|
87
|
+
runtimeUrl: "http://127.0.0.1:1",
|
|
88
|
+
};
|
|
89
|
+
lookup.mockReturnValue(entry);
|
|
90
|
+
process.argv = ["bun", "cadet", "restore", "test", "--from", file];
|
|
91
|
+
await restore();
|
|
92
|
+
expect(hostRestore).toHaveBeenCalledWith(entry, file, { dryRun: false });
|
|
93
|
+
expect(guardianRead).not.toHaveBeenCalled();
|
|
94
|
+
expect(guardianLease).not.toHaveBeenCalled();
|
|
95
|
+
expect(importCloud).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("cloud restore uploads tar and calls the cloud API using the assistant's cloud session", async () => {
|
|
100
|
+
const entry: config.AssistantEntry = {
|
|
101
|
+
assistantId: "cloud-test",
|
|
102
|
+
cloud: "cloud",
|
|
103
|
+
runtimeUrl: "https://cloud.mycadet.ai",
|
|
104
|
+
};
|
|
105
|
+
lookup.mockReturnValue(entry);
|
|
106
|
+
process.argv = ["bun", "cadet", "restore", "cloud-test", "--from", file];
|
|
107
|
+
await restore();
|
|
108
|
+
expect(signedUrl).toHaveBeenCalledWith(
|
|
109
|
+
expect.objectContaining({
|
|
110
|
+
operation: "upload",
|
|
111
|
+
contentType: "application/gzip",
|
|
112
|
+
contentLength: expect.any(Number),
|
|
113
|
+
}),
|
|
114
|
+
"session-token",
|
|
115
|
+
entry.runtimeUrl,
|
|
116
|
+
);
|
|
117
|
+
expect(upload).toHaveBeenCalled();
|
|
118
|
+
expect(importCloud).toHaveBeenCalledWith(
|
|
119
|
+
"root-archive",
|
|
120
|
+
"session-token",
|
|
121
|
+
entry.runtimeUrl,
|
|
122
|
+
);
|
|
123
|
+
expect(hostRestore).not.toHaveBeenCalled();
|
|
124
|
+
expect(guardianRead).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
for (const topology of ["local", "docker", "cloud"] as const) {
|
|
128
|
+
test(`${topology} rejects removed --version before lookup or mutation`, async () => {
|
|
129
|
+
process.argv = [
|
|
130
|
+
"bun",
|
|
131
|
+
"cadet",
|
|
132
|
+
"restore",
|
|
133
|
+
"test",
|
|
134
|
+
"--from",
|
|
135
|
+
file,
|
|
136
|
+
"--version",
|
|
137
|
+
"0.12.10",
|
|
138
|
+
];
|
|
139
|
+
await expect(restore()).rejects.toThrow(
|
|
140
|
+
"Unknown restore option: --version",
|
|
141
|
+
);
|
|
142
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
143
|
+
expect(signedUrl).not.toHaveBeenCalled();
|
|
144
|
+
expect(importCloud).not.toHaveBeenCalled();
|
|
145
|
+
expect(versionRestore).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
}
|