@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,405 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
findAssistantByName,
|
|
7
|
+
saveAssistantEntry,
|
|
8
|
+
type AssistantEntry,
|
|
9
|
+
} from "../lib/assistant-config.js";
|
|
10
|
+
import {
|
|
11
|
+
teleportHostInstallation,
|
|
12
|
+
TeleportRecoveryError,
|
|
13
|
+
type HostTeleportOps,
|
|
14
|
+
} from "../lib/host-teleport.js";
|
|
15
|
+
import {
|
|
16
|
+
createRootBackup,
|
|
17
|
+
LocalRootStorage,
|
|
18
|
+
restoreRootBackup,
|
|
19
|
+
STATE_ROOTS,
|
|
20
|
+
} from "../lib/root-backup.js";
|
|
21
|
+
|
|
22
|
+
let directory: string,
|
|
23
|
+
source: AssistantEntry,
|
|
24
|
+
target: AssistantEntry,
|
|
25
|
+
ops: HostTeleportOps;
|
|
26
|
+
let order: string[],
|
|
27
|
+
running: Map<string, boolean>,
|
|
28
|
+
stores: Map<string, LocalRootStorage>;
|
|
29
|
+
let log: ReturnType<typeof spyOn>, previousLockDir: string | undefined;
|
|
30
|
+
const options = { dryRun: false, keepSource: false };
|
|
31
|
+
async function instance(
|
|
32
|
+
name: string,
|
|
33
|
+
cloud: "local" | "docker",
|
|
34
|
+
byte: number,
|
|
35
|
+
): Promise<AssistantEntry> {
|
|
36
|
+
const root = join(directory, name);
|
|
37
|
+
for (const state of STATE_ROOTS) {
|
|
38
|
+
await mkdir(join(root, state), { recursive: true });
|
|
39
|
+
await writeFile(join(root, state, "state"), `${name}:${state}`, {
|
|
40
|
+
mode: 0o600,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
await writeFile(
|
|
44
|
+
join(root, "gateway-security", "actor-token-signing-key"),
|
|
45
|
+
Buffer.alloc(32, byte),
|
|
46
|
+
);
|
|
47
|
+
await writeFile(
|
|
48
|
+
join(root, "ces-security", "keys.enc"),
|
|
49
|
+
Buffer.from([byte, 0, 255]),
|
|
50
|
+
);
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(root, "ces-security", "store.key"),
|
|
53
|
+
Buffer.alloc(32, byte + 1),
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
assistantId: name,
|
|
57
|
+
cloud,
|
|
58
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
59
|
+
resources: {
|
|
60
|
+
instanceDir: root,
|
|
61
|
+
daemonPort: 1,
|
|
62
|
+
gatewayPort: 2,
|
|
63
|
+
cesPort: 3,
|
|
64
|
+
qdrantPort: 4,
|
|
65
|
+
signingKey: byte.toString(16).padStart(2, "0").repeat(32),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const previousDataHome = process.env.XDG_DATA_HOME;
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
directory = await mkdtemp(join(tmpdir(), "host-teleport-"));
|
|
72
|
+
process.env.XDG_DATA_HOME = directory;
|
|
73
|
+
previousLockDir = process.env.CADET_LOCKFILE_DIR;
|
|
74
|
+
process.env.CADET_LOCKFILE_DIR = join(directory, "registry");
|
|
75
|
+
source = await instance("source", "docker", 7);
|
|
76
|
+
target = await instance("target", "local", 3);
|
|
77
|
+
saveAssistantEntry(source);
|
|
78
|
+
saveAssistantEntry(target);
|
|
79
|
+
order = [];
|
|
80
|
+
running = new Map([
|
|
81
|
+
["source", true],
|
|
82
|
+
["target", true],
|
|
83
|
+
]);
|
|
84
|
+
stores = new Map();
|
|
85
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
86
|
+
ops = {
|
|
87
|
+
storage: (entry) => {
|
|
88
|
+
let storage = stores.get(entry.assistantId);
|
|
89
|
+
if (!storage) {
|
|
90
|
+
storage = new LocalRootStorage(entry);
|
|
91
|
+
storage.running = async () => {
|
|
92
|
+
storage!.initialRunning ??= new Set(
|
|
93
|
+
running.get(entry.assistantId)
|
|
94
|
+
? ["assistant", "gateway", "ces"]
|
|
95
|
+
: [],
|
|
96
|
+
);
|
|
97
|
+
return running.get(entry.assistantId) ?? false;
|
|
98
|
+
};
|
|
99
|
+
storage.resume = async () => {
|
|
100
|
+
order.push(`wake:${entry.assistantId}`);
|
|
101
|
+
running.set(entry.assistantId, true);
|
|
102
|
+
};
|
|
103
|
+
stores.set(entry.assistantId, storage);
|
|
104
|
+
}
|
|
105
|
+
return storage;
|
|
106
|
+
},
|
|
107
|
+
sleep: mock(async (entry) => {
|
|
108
|
+
order.push(`sleep:${entry.assistantId}`);
|
|
109
|
+
running.set(entry.assistantId, false);
|
|
110
|
+
}),
|
|
111
|
+
backup: mock(async (entry) => {
|
|
112
|
+
const file = join(directory, `${entry.assistantId}.tar.gz`),
|
|
113
|
+
storage = ops.storage(entry);
|
|
114
|
+
await createRootBackup({ ...entry, cloud: "local" }, file, {
|
|
115
|
+
storage,
|
|
116
|
+
sleep: () => ops.sleep(entry),
|
|
117
|
+
wake: () => storage.resume(),
|
|
118
|
+
save: saveAssistantEntry,
|
|
119
|
+
});
|
|
120
|
+
order.push(`backup:${entry.assistantId}`);
|
|
121
|
+
return file;
|
|
122
|
+
}),
|
|
123
|
+
version: mock(async () => "0.12.9"),
|
|
124
|
+
hatch: mock(async () => target),
|
|
125
|
+
restore: mock(async (entry, archive) => {
|
|
126
|
+
const storage = ops.storage(entry);
|
|
127
|
+
await restoreRootBackup(
|
|
128
|
+
entry,
|
|
129
|
+
archive,
|
|
130
|
+
{ resume: false, recoveryPath: join(directory, "pre-restore.tar.gz") },
|
|
131
|
+
{
|
|
132
|
+
storage,
|
|
133
|
+
sleep: () => ops.sleep(entry),
|
|
134
|
+
wake: () => storage.resume(),
|
|
135
|
+
save: saveAssistantEntry,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
await storage.resume();
|
|
139
|
+
order.push(`restore:${entry.assistantId}`);
|
|
140
|
+
}),
|
|
141
|
+
retire: mock(async (entry) => {
|
|
142
|
+
order.push(`retire:${entry.assistantId}`);
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
if (previousDataHome === undefined) {
|
|
148
|
+
delete process.env.XDG_DATA_HOME;
|
|
149
|
+
} else {
|
|
150
|
+
process.env.XDG_DATA_HOME = previousDataHome;
|
|
151
|
+
}
|
|
152
|
+
log.mockRestore();
|
|
153
|
+
if (previousLockDir === undefined) {
|
|
154
|
+
delete process.env.CADET_LOCKFILE_DIR;
|
|
155
|
+
} else {
|
|
156
|
+
process.env.CADET_LOCKFILE_DIR = previousLockDir;
|
|
157
|
+
}
|
|
158
|
+
await rm(directory, { recursive: true, force: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("moves all four roots and encrypted key pairs using the exact stopped-source safety tar", async () => {
|
|
162
|
+
await teleportHostInstallation(source, "local", "target", options, ops);
|
|
163
|
+
for (const state of STATE_ROOTS) {
|
|
164
|
+
expect(
|
|
165
|
+
await readFile(
|
|
166
|
+
join(target.resources!.instanceDir, state, "state"),
|
|
167
|
+
"utf8",
|
|
168
|
+
),
|
|
169
|
+
).toBe(`source:${state}`);
|
|
170
|
+
}
|
|
171
|
+
expect(
|
|
172
|
+
await readFile(
|
|
173
|
+
join(target.resources!.instanceDir, "ces-security", "keys.enc"),
|
|
174
|
+
),
|
|
175
|
+
).toEqual(Buffer.from([7, 0, 255]));
|
|
176
|
+
expect(
|
|
177
|
+
await readFile(
|
|
178
|
+
join(target.resources!.instanceDir, "ces-security", "store.key"),
|
|
179
|
+
),
|
|
180
|
+
).toEqual(Buffer.alloc(32, 8));
|
|
181
|
+
expect(findAssistantByName("target")!.resources!.signingKey).toBe(
|
|
182
|
+
"07".repeat(32),
|
|
183
|
+
);
|
|
184
|
+
expect(order).toEqual([
|
|
185
|
+
"sleep:source",
|
|
186
|
+
"backup:source",
|
|
187
|
+
"sleep:target",
|
|
188
|
+
"backup:target",
|
|
189
|
+
"wake:target",
|
|
190
|
+
"restore:target",
|
|
191
|
+
"retire:source",
|
|
192
|
+
]);
|
|
193
|
+
expect(ops.restore).toHaveBeenCalledWith(
|
|
194
|
+
expect.objectContaining({ assistantId: "target" }),
|
|
195
|
+
join(directory, "source.tar.gz"),
|
|
196
|
+
join(directory, "target.tar.gz"),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
test("dry run creates no archives and does not stop or hatch services", async () => {
|
|
200
|
+
await teleportHostInstallation(
|
|
201
|
+
source,
|
|
202
|
+
"local",
|
|
203
|
+
"target",
|
|
204
|
+
{ ...options, dryRun: true },
|
|
205
|
+
ops,
|
|
206
|
+
);
|
|
207
|
+
expect(order).toEqual([]);
|
|
208
|
+
expect(ops.version).not.toHaveBeenCalled();
|
|
209
|
+
expect(ops.backup).not.toHaveBeenCalled();
|
|
210
|
+
expect(ops.hatch).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
test("keep-source retains the stopped original", async () => {
|
|
213
|
+
await teleportHostInstallation(
|
|
214
|
+
source,
|
|
215
|
+
"local",
|
|
216
|
+
"target",
|
|
217
|
+
{ ...options, keepSource: true },
|
|
218
|
+
ops,
|
|
219
|
+
);
|
|
220
|
+
expect(running.get("source")).toBe(false);
|
|
221
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
test("source backup failure wakes source without touching target", async () => {
|
|
224
|
+
ops.backup = mock(async () => {
|
|
225
|
+
throw new Error("unreadable credential store");
|
|
226
|
+
});
|
|
227
|
+
await expect(
|
|
228
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
229
|
+
).rejects.toThrow("unreadable credential store");
|
|
230
|
+
expect(order).toEqual(["sleep:source", "wake:source"]);
|
|
231
|
+
expect(ops.restore).not.toHaveBeenCalled();
|
|
232
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
test("graceful shutdown failure never captures a live installation", async () => {
|
|
235
|
+
ops.sleep = mock(async () => {
|
|
236
|
+
throw new Error("graceful timeout");
|
|
237
|
+
});
|
|
238
|
+
await expect(
|
|
239
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
240
|
+
).rejects.toThrow("graceful timeout");
|
|
241
|
+
expect(ops.backup).not.toHaveBeenCalled();
|
|
242
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
test("source that fails to stop is rejected even if sleep returned", async () => {
|
|
245
|
+
ops.sleep = mock(async () => {});
|
|
246
|
+
await expect(
|
|
247
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
248
|
+
).rejects.toThrow("still running");
|
|
249
|
+
expect(ops.backup).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
test("target capture failure resumes both original installations", async () => {
|
|
252
|
+
const backup = ops.backup;
|
|
253
|
+
ops.backup = mock(async (entry) => {
|
|
254
|
+
if (entry.assistantId === target.assistantId) {
|
|
255
|
+
throw new Error("target capture failed");
|
|
256
|
+
}
|
|
257
|
+
return backup(entry);
|
|
258
|
+
});
|
|
259
|
+
await expect(
|
|
260
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
261
|
+
).rejects.toThrow("target capture failed");
|
|
262
|
+
expect(order.slice(-2)).toEqual(["wake:target", "wake:source"]);
|
|
263
|
+
expect(ops.restore).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
test("failed import retains source and resumes recovered target", async () => {
|
|
266
|
+
ops.restore = mock(async () => {
|
|
267
|
+
throw new Error("import failed; original data recovered");
|
|
268
|
+
});
|
|
269
|
+
await expect(
|
|
270
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
271
|
+
).rejects.toThrow("import failed");
|
|
272
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
273
|
+
expect(order.slice(-2)).toEqual(["wake:target", "wake:source"]);
|
|
274
|
+
});
|
|
275
|
+
test("failed recovery leaves damaged target stopped", async () => {
|
|
276
|
+
ops.restore = mock(async () => {
|
|
277
|
+
throw new TeleportRecoveryError([], "recovery failed");
|
|
278
|
+
});
|
|
279
|
+
await expect(
|
|
280
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
281
|
+
).rejects.toThrow("recovery failed");
|
|
282
|
+
expect(running.get("target")).toBe(false);
|
|
283
|
+
expect(order).not.toContain("wake:target");
|
|
284
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
test("originally sleeping source stays asleep on capture failure", async () => {
|
|
287
|
+
running.set("source", false);
|
|
288
|
+
ops.backup = mock(async () => {
|
|
289
|
+
throw new Error("capture failed");
|
|
290
|
+
});
|
|
291
|
+
await expect(
|
|
292
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
293
|
+
).rejects.toThrow("capture failed");
|
|
294
|
+
expect(order).not.toContain("wake:source");
|
|
295
|
+
});
|
|
296
|
+
test("new target receives safety tar at hatch and becomes ready before source retirement", async () => {
|
|
297
|
+
await teleportHostInstallation(source, "local", "fresh-target", options, ops);
|
|
298
|
+
expect(ops.hatch).toHaveBeenCalledWith(
|
|
299
|
+
"local",
|
|
300
|
+
"fresh-target",
|
|
301
|
+
join(directory, "source.tar.gz"),
|
|
302
|
+
);
|
|
303
|
+
expect(order.indexOf("wake:target")).toBeLessThan(
|
|
304
|
+
order.indexOf("retire:source"),
|
|
305
|
+
);
|
|
306
|
+
expect(ops.restore).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
test("failed new target readiness cleans it up before waking source", async () => {
|
|
309
|
+
ops.storage(target).resume = async () => {
|
|
310
|
+
throw new Error("migration failed");
|
|
311
|
+
};
|
|
312
|
+
await expect(
|
|
313
|
+
teleportHostInstallation(source, "local", "fresh-target", options, ops),
|
|
314
|
+
).rejects.toThrow("migration failed");
|
|
315
|
+
expect(order.slice(-3)).toEqual([
|
|
316
|
+
"sleep:target",
|
|
317
|
+
"retire:target",
|
|
318
|
+
"wake:source",
|
|
319
|
+
]);
|
|
320
|
+
expect(order).not.toContain("retire:source");
|
|
321
|
+
});
|
|
322
|
+
test("hatch failure retains tar and wakes source", async () => {
|
|
323
|
+
ops.hatch = mock(async () => {
|
|
324
|
+
throw new Error("image pull failed");
|
|
325
|
+
});
|
|
326
|
+
await expect(
|
|
327
|
+
teleportHostInstallation(source, "local", "fresh-target", options, ops),
|
|
328
|
+
).rejects.toThrow("image pull failed");
|
|
329
|
+
expect(order.at(-1)).toBe("wake:source");
|
|
330
|
+
expect(await readFile(join(directory, "source.tar.gz"))).not.toHaveLength(0);
|
|
331
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
test("multi-patch downgrade fails before shutdown", async () => {
|
|
334
|
+
ops.version = mock(async (entry) => (entry === source ? "0.12.9" : "0.12.5"));
|
|
335
|
+
await expect(
|
|
336
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
337
|
+
).rejects.toThrow("older than source");
|
|
338
|
+
expect(order).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
test("target environment mismatch fails before shutdown", async () => {
|
|
341
|
+
await expect(
|
|
342
|
+
teleportHostInstallation(source, "docker", "target", options, ops),
|
|
343
|
+
).rejects.toThrow("not docker");
|
|
344
|
+
expect(order).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
test("source restarted during transfer is retained to preserve newer writes", async () => {
|
|
347
|
+
const restore = ops.restore;
|
|
348
|
+
ops.restore = mock(async (entry, archive, recovery) => {
|
|
349
|
+
await restore(entry, archive, recovery);
|
|
350
|
+
running.set("source", true);
|
|
351
|
+
});
|
|
352
|
+
await expect(
|
|
353
|
+
teleportHostInstallation(source, "local", "target", options, ops),
|
|
354
|
+
).rejects.toThrow("restarted during teleport");
|
|
355
|
+
expect(ops.retire).not.toHaveBeenCalled();
|
|
356
|
+
expect(order).not.toContain("wake:source");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("holds source and target operation locks through import and releases them afterward", async () => {
|
|
360
|
+
let release!: () => void;
|
|
361
|
+
const blocked = new Promise<void>((resolve) => {
|
|
362
|
+
release = resolve;
|
|
363
|
+
});
|
|
364
|
+
let entered!: () => void;
|
|
365
|
+
const importing = new Promise<void>((resolve) => {
|
|
366
|
+
entered = resolve;
|
|
367
|
+
});
|
|
368
|
+
const restore = ops.restore;
|
|
369
|
+
ops.restore = mock(async (entry, archive, recovery) => {
|
|
370
|
+
entered();
|
|
371
|
+
await blocked;
|
|
372
|
+
await restore(entry, archive, recovery);
|
|
373
|
+
});
|
|
374
|
+
const transfer = teleportHostInstallation(
|
|
375
|
+
source,
|
|
376
|
+
"local",
|
|
377
|
+
"target",
|
|
378
|
+
options,
|
|
379
|
+
ops,
|
|
380
|
+
);
|
|
381
|
+
await importing;
|
|
382
|
+
try {
|
|
383
|
+
for (const entry of [source, target]) {
|
|
384
|
+
const storage = ops.storage(entry);
|
|
385
|
+
await expect(
|
|
386
|
+
createRootBackup(entry, join(directory, "competing.tar.gz"), {
|
|
387
|
+
storage,
|
|
388
|
+
sleep: () => ops.sleep(entry),
|
|
389
|
+
wake: () => storage.resume(),
|
|
390
|
+
save: saveAssistantEntry,
|
|
391
|
+
}),
|
|
392
|
+
).rejects.toThrow("Another backup/restore owns");
|
|
393
|
+
}
|
|
394
|
+
} finally {
|
|
395
|
+
release();
|
|
396
|
+
}
|
|
397
|
+
await transfer;
|
|
398
|
+
const storage = ops.storage(source);
|
|
399
|
+
await createRootBackup(source, join(directory, "after.tar.gz"), {
|
|
400
|
+
storage,
|
|
401
|
+
sleep: () => ops.sleep(source),
|
|
402
|
+
wake: () => storage.resume(),
|
|
403
|
+
save: saveAssistantEntry,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -51,6 +51,10 @@ mock.module("node:fs", () => ({
|
|
|
51
51
|
readFileSync: readFileSyncMock,
|
|
52
52
|
}));
|
|
53
53
|
|
|
54
|
+
const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
|
|
55
|
+
async () => "1.2.3",
|
|
56
|
+
);
|
|
57
|
+
|
|
54
58
|
const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
55
59
|
async () => true,
|
|
56
60
|
);
|
|
@@ -58,6 +62,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
|
58
62
|
mock.module("../lib/http-client.js", () => ({
|
|
59
63
|
...httpClient,
|
|
60
64
|
waitForDaemonReady: waitForDaemonReadyMock,
|
|
65
|
+
fetchCurrentVersion: fetchCurrentVersionMock,
|
|
61
66
|
}));
|
|
62
67
|
|
|
63
68
|
// Restore the real modules once this file finishes so the mocks do not leak
|
|
@@ -72,7 +77,6 @@ import {
|
|
|
72
77
|
buildIngressNginxConfig,
|
|
73
78
|
hasIpv6Loopback,
|
|
74
79
|
buildRemoteWebIndexHtml,
|
|
75
|
-
cloudWebHubUrl,
|
|
76
80
|
MAX_PRESERVED_MODULE_PRELOADS,
|
|
77
81
|
ensureTunnelEdge,
|
|
78
82
|
EDGE_TEMPLATE_VERSION,
|
|
@@ -124,6 +128,8 @@ afterEach(() => {
|
|
|
124
128
|
existsSyncMock.mockImplementation(realFs.existsSync);
|
|
125
129
|
readFileSyncMock.mockReset();
|
|
126
130
|
readFileSyncMock.mockImplementation(realFs.readFileSync);
|
|
131
|
+
fetchCurrentVersionMock.mockReset();
|
|
132
|
+
fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
|
|
127
133
|
waitForDaemonReadyMock.mockReset();
|
|
128
134
|
waitForDaemonReadyMock.mockImplementation(async () => true);
|
|
129
135
|
for (const dir of workspaces.splice(0)) {
|
|
@@ -307,7 +313,7 @@ describe("buildIngressNginxConfig", () => {
|
|
|
307
313
|
).toEqual([]);
|
|
308
314
|
});
|
|
309
315
|
|
|
310
|
-
test("revalidates the shell and forbids storing
|
|
316
|
+
test("revalidates the shell and forbids storing inline config", () => {
|
|
311
317
|
const blocks = locationBlocks(remoteConf);
|
|
312
318
|
const cacheControlOf = (matcher: string) =>
|
|
313
319
|
blocks
|
|
@@ -318,11 +324,8 @@ describe("buildIngressNginxConfig", () => {
|
|
|
318
324
|
expect(cacheControlOf("^~ /assistant/assets/")).toBe(
|
|
319
325
|
"public, max-age=31536000, immutable",
|
|
320
326
|
);
|
|
321
|
-
//
|
|
322
|
-
// refuses storage. A second no-store means a revalidating location
|
|
323
|
-
// regressed to re-sending its whole body on every load.
|
|
327
|
+
// Inline config has no validator and must not be stored.
|
|
324
328
|
expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
|
|
325
|
-
expect(remoteConf.match(/no-store/g)).toHaveLength(1);
|
|
326
329
|
});
|
|
327
330
|
|
|
328
331
|
test("serves remote web config for the SPA", () => {
|
|
@@ -527,18 +530,6 @@ describe("buildRemoteWebIndexHtml", () => {
|
|
|
527
530
|
});
|
|
528
531
|
});
|
|
529
532
|
|
|
530
|
-
describe("cloudWebHubUrl", () => {
|
|
531
|
-
test("maps build environments to their cloud SPA base", () => {
|
|
532
|
-
expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
|
|
533
|
-
expect(cloudWebHubUrl("staging")).toBe(
|
|
534
|
-
"https://staging.mycadet.ai/assistant",
|
|
535
|
-
);
|
|
536
|
-
expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
|
|
537
|
-
expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
|
|
538
|
-
expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
533
|
describe("nginx ingress process state", () => {
|
|
543
534
|
function writeIngressState(workspaceDir: string, listenPort: number): void {
|
|
544
535
|
writeFileSync(
|
|
@@ -746,17 +737,19 @@ function mockWebDistMissing(): void {
|
|
|
746
737
|
}
|
|
747
738
|
|
|
748
739
|
/** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
|
|
749
|
-
function mockWebDistPresent(): void {
|
|
740
|
+
function mockWebDistPresent(version: string | null = "1.2.3"): void {
|
|
750
741
|
existsSyncMock.mockImplementation((path) =>
|
|
751
742
|
String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
|
|
752
743
|
);
|
|
753
744
|
readFileSyncMock.mockImplementation(((path, options) =>
|
|
754
745
|
String(path).endsWith(WEB_INDEX_SUFFIX)
|
|
755
746
|
? FAKE_INDEX_HTML
|
|
756
|
-
:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
747
|
+
: String(path).endsWith("release.json")
|
|
748
|
+
? JSON.stringify(version === null ? {} : { version })
|
|
749
|
+
: realFs.readFileSync(
|
|
750
|
+
path as never,
|
|
751
|
+
options as never,
|
|
752
|
+
)) as typeof readFileSync);
|
|
760
753
|
}
|
|
761
754
|
|
|
762
755
|
/** Record a running edge: ingress state, live pidfile, matching ps output. */
|
|
@@ -863,6 +856,7 @@ function spaConfigHash(
|
|
|
863
856
|
hubUrl: PRODUCTION_HUB_URL,
|
|
864
857
|
},
|
|
865
858
|
webDistDir: opts.webDistDir ?? findWebDistDir(),
|
|
859
|
+
version: "1.2.3",
|
|
866
860
|
indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
|
|
867
861
|
}),
|
|
868
862
|
)
|
|
@@ -888,6 +882,70 @@ function pinProductionEnvironment(): void {
|
|
|
888
882
|
describe("startRemoteWebIngress", () => {
|
|
889
883
|
pinProductionEnvironment();
|
|
890
884
|
|
|
885
|
+
for (const [webVersion, runtimeVersion, ready] of [
|
|
886
|
+
["1.2.3", "1.2.3", true],
|
|
887
|
+
["1.2.3", "v1.2.3", true],
|
|
888
|
+
["1.2.2", "1.2.3", false],
|
|
889
|
+
["1.2.3", undefined, false],
|
|
890
|
+
[null, "1.2.3", false],
|
|
891
|
+
] as const) {
|
|
892
|
+
test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
|
|
893
|
+
const ws = makeWorkspace();
|
|
894
|
+
mockNginxInstalled();
|
|
895
|
+
mockNginxSpawn();
|
|
896
|
+
mockWebDistPresent(webVersion);
|
|
897
|
+
fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
|
|
898
|
+
const result = await startRemoteWebIngress({
|
|
899
|
+
workspaceDir: ws,
|
|
900
|
+
gatewayPort: 7830,
|
|
901
|
+
listenPort: 7845,
|
|
902
|
+
assistantId: "assistant-1",
|
|
903
|
+
});
|
|
904
|
+
expect(result.status).toBe("started");
|
|
905
|
+
expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
|
|
906
|
+
"http://127.0.0.1:7830",
|
|
907
|
+
);
|
|
908
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
909
|
+
const detail = conf
|
|
910
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
911
|
+
?.split("\n }")[0];
|
|
912
|
+
expect(detail).toContain(`return ${ready ? 200 : 409} `);
|
|
913
|
+
if (ready) {
|
|
914
|
+
expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
|
|
915
|
+
} else {
|
|
916
|
+
expect(detail).not.toContain('\\"url\\"');
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
test("restarts an edge when its verified assistant/UI release changes", async () => {
|
|
922
|
+
const ws = makeWorkspace();
|
|
923
|
+
mockNginxInstalled();
|
|
924
|
+
mockNginxSpawn();
|
|
925
|
+
mockWebDistPresent("1.2.3");
|
|
926
|
+
fetchCurrentVersionMock.mockResolvedValue("1.2.4");
|
|
927
|
+
const pid = mockRunningEdge(ws, {
|
|
928
|
+
listenPort: 7845,
|
|
929
|
+
includeWebApp: true,
|
|
930
|
+
gatewayPort: 7830,
|
|
931
|
+
remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
|
|
932
|
+
});
|
|
933
|
+
const edge = mockKillableNginx(pid);
|
|
934
|
+
const result = await startRemoteWebIngress({
|
|
935
|
+
workspaceDir: ws,
|
|
936
|
+
gatewayPort: 7830,
|
|
937
|
+
listenPort: 7845,
|
|
938
|
+
assistantId: "assistant-1",
|
|
939
|
+
});
|
|
940
|
+
expect(result.status).toBe("started");
|
|
941
|
+
expect(edge.killed()).toBe(true);
|
|
942
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
943
|
+
const detail = conf
|
|
944
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
945
|
+
?.split("\n }")[0];
|
|
946
|
+
expect(detail).toContain("return 409 ");
|
|
947
|
+
});
|
|
948
|
+
|
|
891
949
|
test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
|
|
892
950
|
const ws = makeWorkspace();
|
|
893
951
|
mockNginxInstalled();
|
|
@@ -2261,3 +2319,104 @@ describe("stopContainerTunnelEdge", () => {
|
|
|
2261
2319
|
expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
|
|
2262
2320
|
});
|
|
2263
2321
|
});
|
|
2322
|
+
|
|
2323
|
+
describe("assistant metadata API", () => {
|
|
2324
|
+
function config(id = "assistant-1") {
|
|
2325
|
+
return buildIngressNginxConfig({
|
|
2326
|
+
gatewayPort: 7830,
|
|
2327
|
+
listenPort: 7840,
|
|
2328
|
+
remoteWebIngress: {
|
|
2329
|
+
webDistDir: "/tmp/web",
|
|
2330
|
+
version: "1.2.3",
|
|
2331
|
+
assistantId: id,
|
|
2332
|
+
assistantName: "My assistant",
|
|
2333
|
+
config: { appAllowedOrigins: ["https://parent.example"] },
|
|
2334
|
+
},
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
function body(conf: string, path: string, status = 200): unknown {
|
|
2338
|
+
const location = conf
|
|
2339
|
+
.split(`location = "${path}" {`)[1]
|
|
2340
|
+
?.split("\n }")[0];
|
|
2341
|
+
const value = location?.match(/return (200|409) ("(?:\\.|[^"\\])*");/);
|
|
2342
|
+
expect(Number(value?.[1])).toBe(status);
|
|
2343
|
+
if (!value) {
|
|
2344
|
+
throw new Error(`Missing metadata for ${path}`);
|
|
2345
|
+
}
|
|
2346
|
+
return JSON.parse(JSON.parse(value[2]));
|
|
2347
|
+
}
|
|
2348
|
+
test("lists only this host's assistant and returns its actual UI version and URL", () => {
|
|
2349
|
+
const conf = config();
|
|
2350
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2351
|
+
results: [{ id: "assistant-1", name: "My assistant" }],
|
|
2352
|
+
next: null,
|
|
2353
|
+
});
|
|
2354
|
+
expect(body(conf, "/v1/assistants/assistant-1/")).toEqual({
|
|
2355
|
+
id: "assistant-1",
|
|
2356
|
+
name: "My assistant",
|
|
2357
|
+
status: "active",
|
|
2358
|
+
version: "1.2.3",
|
|
2359
|
+
url: "assistant/",
|
|
2360
|
+
});
|
|
2361
|
+
expect(conf).not.toContain('location = "/v1/assistants/other/"');
|
|
2362
|
+
});
|
|
2363
|
+
test("metadata supports both slash forms and only read methods", () => {
|
|
2364
|
+
const conf = config();
|
|
2365
|
+
for (const path of [
|
|
2366
|
+
"/v1/assistants",
|
|
2367
|
+
"/v1/assistants/",
|
|
2368
|
+
"/v1/assistants/assistant-1",
|
|
2369
|
+
"/v1/assistants/assistant-1/",
|
|
2370
|
+
]) {
|
|
2371
|
+
expect(body(conf, path)).toBeDefined();
|
|
2372
|
+
}
|
|
2373
|
+
expect(conf).toContain(
|
|
2374
|
+
"if ($request_method !~ ^(GET|HEAD)$) { return 405; }",
|
|
2375
|
+
);
|
|
2376
|
+
expect(conf).toContain(
|
|
2377
|
+
'"https://parent.example" "https://parent.example";',
|
|
2378
|
+
);
|
|
2379
|
+
expect(conf).toContain(
|
|
2380
|
+
"add_header Access-Control-Allow-Origin $cadet_app_origin always;",
|
|
2381
|
+
);
|
|
2382
|
+
});
|
|
2383
|
+
test("unknown identity yields an empty list without inventing an assistant ID", () => {
|
|
2384
|
+
const conf = buildIngressNginxConfig({
|
|
2385
|
+
gatewayPort: 7830,
|
|
2386
|
+
listenPort: 7840,
|
|
2387
|
+
remoteWebIngress: { webDistDir: "/tmp/web" },
|
|
2388
|
+
});
|
|
2389
|
+
expect(body(conf, "/v1/assistants/")).toEqual({ results: [], next: null });
|
|
2390
|
+
});
|
|
2391
|
+
test("a known assistant without a UI bundle returns 409 on both detail paths", () => {
|
|
2392
|
+
const conf = buildIngressNginxConfig({
|
|
2393
|
+
gatewayPort: 7830,
|
|
2394
|
+
listenPort: 7840,
|
|
2395
|
+
remoteWebIngress: { webDistDir: "/tmp/web", assistantId: "assistant-1" },
|
|
2396
|
+
});
|
|
2397
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2398
|
+
results: [{ id: "assistant-1" }],
|
|
2399
|
+
next: null,
|
|
2400
|
+
});
|
|
2401
|
+
for (const path of [
|
|
2402
|
+
"/v1/assistants/assistant-1",
|
|
2403
|
+
"/v1/assistants/assistant-1/",
|
|
2404
|
+
]) {
|
|
2405
|
+
expect(body(conf, path, 409)).toEqual({
|
|
2406
|
+
detail: "assistant is not ready",
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
test("invalid browser origins fail before nginx starts", () => {
|
|
2411
|
+
expect(() =>
|
|
2412
|
+
buildIngressNginxConfig({
|
|
2413
|
+
gatewayPort: 7830,
|
|
2414
|
+
listenPort: 7840,
|
|
2415
|
+
remoteWebIngress: {
|
|
2416
|
+
webDistDir: "/tmp/web",
|
|
2417
|
+
config: { appAllowedOrigins: ["https://parent.example/path"] },
|
|
2418
|
+
},
|
|
2419
|
+
}),
|
|
2420
|
+
).toThrow("App origins");
|
|
2421
|
+
});
|
|
2422
|
+
});
|
|
@@ -107,7 +107,7 @@ describe("getKnownPidsFromAssistants", () => {
|
|
|
107
107
|
const entry: AssistantEntry = {
|
|
108
108
|
assistantId: "managed-1",
|
|
109
109
|
runtimeUrl: "https://cloud.mycadet.ai/foo",
|
|
110
|
-
cloud: "
|
|
110
|
+
cloud: "cloud",
|
|
111
111
|
};
|
|
112
112
|
const pids = getKnownPidsFromAssistants([entry]);
|
|
113
113
|
expect(pids.size).toBe(0);
|