@mycadet/cli 0.12.10 → 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 +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- 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-login-poll.test.ts +69 -0
- 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 +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +233 -36
- 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 +8 -7
- 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 +17 -7
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +13 -3
- 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 +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -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/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +84 -157
- 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 +20 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- 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 +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- 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 +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +254 -36
- 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/tunnel.ts +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/lib/xdg-log.ts +11 -0
- 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,506 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import cliPkg from "../../package.json";
|
|
4
|
+
import type { AssistantEntry } from "./assistant-config.js";
|
|
5
|
+
import {
|
|
6
|
+
findAssistantByName,
|
|
7
|
+
removeAssistantEntry,
|
|
8
|
+
setActiveAssistant,
|
|
9
|
+
} from "./assistant-config.js";
|
|
10
|
+
import { createBackup, pruneOldBackups } from "./backup-ops.js";
|
|
11
|
+
import {
|
|
12
|
+
dockerResourceNames,
|
|
13
|
+
hatchDocker,
|
|
14
|
+
retireDocker,
|
|
15
|
+
startContainers,
|
|
16
|
+
} from "./docker.js";
|
|
17
|
+
import { hatchLocal } from "./hatch-local.js";
|
|
18
|
+
import { retireLocal } from "./retire-local.js";
|
|
19
|
+
import { validateAssistantName } from "./retire-archive.js";
|
|
20
|
+
import {
|
|
21
|
+
DockerRootStorage,
|
|
22
|
+
LocalRootStorage,
|
|
23
|
+
restoreRootBackup,
|
|
24
|
+
stageRootArchive,
|
|
25
|
+
STATE_ROOTS,
|
|
26
|
+
withRootOperationLock,
|
|
27
|
+
} from "./root-backup.js";
|
|
28
|
+
import { DOCKER_STATEFUL_SET_SPEC } from "./statefulset.js";
|
|
29
|
+
import { execOutput } from "./step-runner.js";
|
|
30
|
+
import { buildReplayState } from "./upgrade-lifecycle.js";
|
|
31
|
+
import { fetchCurrentVersion } from "./http-client.js";
|
|
32
|
+
import { compareVersions } from "./version-compat.js";
|
|
33
|
+
import { sleepAssistant } from "../commands/sleep.js";
|
|
34
|
+
import { generateInstanceName } from "./random-name.js";
|
|
35
|
+
import { GATEWAY_INTERNAL_PORT } from "./environments/paths.js";
|
|
36
|
+
|
|
37
|
+
type HostStorage = LocalRootStorage | DockerRootStorage;
|
|
38
|
+
type HostEnvironment = "local" | "docker";
|
|
39
|
+
|
|
40
|
+
export class TeleportRecoveryError extends AggregateError {}
|
|
41
|
+
|
|
42
|
+
export interface HostTeleportOps {
|
|
43
|
+
storage(entry: AssistantEntry): HostStorage;
|
|
44
|
+
sleep(entry: AssistantEntry): Promise<void>;
|
|
45
|
+
backup(entry: AssistantEntry): Promise<string>;
|
|
46
|
+
version(entry: AssistantEntry): Promise<string | undefined>;
|
|
47
|
+
hatch(
|
|
48
|
+
env: HostEnvironment,
|
|
49
|
+
name: string | undefined,
|
|
50
|
+
archive: string,
|
|
51
|
+
): Promise<AssistantEntry>;
|
|
52
|
+
restore(
|
|
53
|
+
entry: AssistantEntry,
|
|
54
|
+
archive: string,
|
|
55
|
+
recovery: string,
|
|
56
|
+
): Promise<void>;
|
|
57
|
+
retire(entry: AssistantEntry): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function storageFor(entry: AssistantEntry): HostStorage {
|
|
61
|
+
return entry.cloud === "docker"
|
|
62
|
+
? new DockerRootStorage(entry)
|
|
63
|
+
: new LocalRootStorage(entry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defaultOps: HostTeleportOps = {
|
|
67
|
+
storage: storageFor,
|
|
68
|
+
sleep: (entry) =>
|
|
69
|
+
sleepAssistant(entry, { force: false, wait: false, waitMs: null }),
|
|
70
|
+
backup: async (entry) => {
|
|
71
|
+
const archive = await createBackup(entry.runtimeUrl, entry.assistantId, {
|
|
72
|
+
prefix: `${entry.assistantId}-pre-teleport`,
|
|
73
|
+
});
|
|
74
|
+
if (!archive) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Could not capture '${entry.assistantId}'; teleport aborted.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
console.log(`Backup saved: ${archive}`);
|
|
80
|
+
return archive;
|
|
81
|
+
},
|
|
82
|
+
version: readHostRuntimeVersion,
|
|
83
|
+
hatch: async (env, name, initialArchive) => {
|
|
84
|
+
const instanceName = generateInstanceName(name ?? null);
|
|
85
|
+
if (findAssistantByName(instanceName)) {
|
|
86
|
+
throw new Error(`Assistant '${instanceName}' already exists.`);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
if (env === "docker") {
|
|
90
|
+
await hatchDocker({
|
|
91
|
+
name: instanceName,
|
|
92
|
+
setupProviderCredentials: false,
|
|
93
|
+
initialArchive,
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
await hatchLocal({
|
|
97
|
+
name: instanceName,
|
|
98
|
+
setupProviderCredentials: false,
|
|
99
|
+
initialArchive,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const entry = findAssistantByName(instanceName);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
throw new Error("Could not find the newly hatched teleport target.");
|
|
105
|
+
}
|
|
106
|
+
return entry;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const entry = findAssistantByName(instanceName);
|
|
109
|
+
if (entry) {
|
|
110
|
+
await defaultOps.sleep(entry);
|
|
111
|
+
await defaultOps.retire(entry);
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
restore: restoreHostTeleportTarget,
|
|
117
|
+
retire: async (entry) => {
|
|
118
|
+
if (entry.cloud === "docker") {
|
|
119
|
+
await retireDocker(entry.assistantId);
|
|
120
|
+
} else {
|
|
121
|
+
await retireLocal(entry.assistantId, entry);
|
|
122
|
+
}
|
|
123
|
+
removeAssistantEntry(entry.assistantId);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export async function readHostRuntimeVersion(
|
|
128
|
+
entry: AssistantEntry,
|
|
129
|
+
): Promise<string | undefined> {
|
|
130
|
+
if (entry.cloud !== "docker") {
|
|
131
|
+
return (
|
|
132
|
+
(await fetchCurrentVersion(entry.runtimeUrl)) ??
|
|
133
|
+
entry.resources?.runtimeVersion
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const container = dockerResourceNames(entry.assistantId).gatewayContainer;
|
|
138
|
+
const state = await new DockerRootStorage(entry).inspect(container);
|
|
139
|
+
const configured = state.Config.Env.find((value) =>
|
|
140
|
+
value.startsWith("GATEWAY_PORT="),
|
|
141
|
+
)?.slice("GATEWAY_PORT=".length);
|
|
142
|
+
const port = configured ? Number(configured) : GATEWAY_INTERNAL_PORT;
|
|
143
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
144
|
+
throw new Error("Invalid Docker gateway health port.");
|
|
145
|
+
}
|
|
146
|
+
const result = await execOutput(
|
|
147
|
+
"docker",
|
|
148
|
+
[
|
|
149
|
+
"exec",
|
|
150
|
+
container,
|
|
151
|
+
"bun",
|
|
152
|
+
"--eval",
|
|
153
|
+
"const r=await fetch(process.argv.at(-1),{signal:AbortSignal.timeout(1500)});if(!r.ok)process.exit(1);console.log(JSON.stringify(await r.json()))",
|
|
154
|
+
`http://127.0.0.1:${port}/healthz`,
|
|
155
|
+
],
|
|
156
|
+
{ timeoutMs: 3000 },
|
|
157
|
+
);
|
|
158
|
+
const parsed = z
|
|
159
|
+
.object({ version: z.string() })
|
|
160
|
+
.safeParse(JSON.parse(result));
|
|
161
|
+
if (parsed.success) {
|
|
162
|
+
return parsed.data.version;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Sleeping containers still carry their recorded release tag.
|
|
166
|
+
}
|
|
167
|
+
return entry.containerInfo?.gatewayImage.match(
|
|
168
|
+
/:v?(\d+\.\d+\.\d+(?:-[\w.-]+)?)$/,
|
|
169
|
+
)?.[1];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkVersion(
|
|
173
|
+
source: string | undefined,
|
|
174
|
+
target: string | undefined,
|
|
175
|
+
): void {
|
|
176
|
+
const comparison = source && target ? compareVersions(target, source) : null;
|
|
177
|
+
if (comparison !== null && comparison < 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Target runtime ${target} is older than source ${source}. Upgrade the target before teleporting.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function startAndCheck(storage: HostStorage): Promise<void> {
|
|
185
|
+
storage.initialRunning =
|
|
186
|
+
storage instanceof DockerRootStorage
|
|
187
|
+
? new Set(Object.values(storage.containers))
|
|
188
|
+
: new Set(["ces", "assistant", "gateway"]);
|
|
189
|
+
await storage.resume();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function teleportHostInstallation(
|
|
193
|
+
source: AssistantEntry,
|
|
194
|
+
targetEnv: HostEnvironment,
|
|
195
|
+
targetName: string | undefined,
|
|
196
|
+
options: { dryRun: boolean; keepSource: boolean },
|
|
197
|
+
ops = defaultOps,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const existing = targetName ? findAssistantByName(targetName) : null;
|
|
200
|
+
const work = () =>
|
|
201
|
+
performHostTeleport(source, targetEnv, targetName, existing, options, ops);
|
|
202
|
+
if (options.dryRun) {
|
|
203
|
+
return work();
|
|
204
|
+
}
|
|
205
|
+
return withRootOperationLock(source, () =>
|
|
206
|
+
existing ? withRootOperationLock(existing, work) : work(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function performHostTeleport(
|
|
211
|
+
source: AssistantEntry,
|
|
212
|
+
targetEnv: HostEnvironment,
|
|
213
|
+
targetName: string | undefined,
|
|
214
|
+
existing: AssistantEntry | null,
|
|
215
|
+
options: { dryRun: boolean; keepSource: boolean },
|
|
216
|
+
ops: HostTeleportOps,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
if (existing && existing.cloud !== targetEnv) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Assistant '${targetName}' is a ${existing.cloud} assistant, not ${targetEnv}.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (targetName && !existing) {
|
|
224
|
+
validateAssistantName(targetName);
|
|
225
|
+
}
|
|
226
|
+
if (options.dryRun) {
|
|
227
|
+
console.log(
|
|
228
|
+
`Would move ${STATE_ROOTS.join(", ")} from '${source.assistantId}' to ${existing ? `'${existing.assistantId}'` : `a new ${targetEnv} assistant`}.`,
|
|
229
|
+
);
|
|
230
|
+
console.log(
|
|
231
|
+
"Would gracefully sleep services, save root tar safety backups, restore all roots, and verify the destination.",
|
|
232
|
+
);
|
|
233
|
+
console.log(
|
|
234
|
+
options.keepSource
|
|
235
|
+
? "Would keep the source stopped."
|
|
236
|
+
: "Would retire the source only after the destination is ready.",
|
|
237
|
+
);
|
|
238
|
+
console.log("Dry run complete. No services stopped or archives created.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sourceVersion = await ops.version(source);
|
|
243
|
+
checkVersion(
|
|
244
|
+
sourceVersion,
|
|
245
|
+
existing ? await ops.version(existing) : cliPkg.version,
|
|
246
|
+
);
|
|
247
|
+
const sourceStorage = ops.storage(source);
|
|
248
|
+
const sourceWasRunning = await sourceStorage.running();
|
|
249
|
+
let target: AssistantEntry | undefined;
|
|
250
|
+
let ready = false;
|
|
251
|
+
try {
|
|
252
|
+
await ops.sleep(source);
|
|
253
|
+
if (await sourceStorage.running()) {
|
|
254
|
+
throw new Error("Source services are still running; teleport aborted.");
|
|
255
|
+
}
|
|
256
|
+
// This exact snapshot is both the safety backup and the transferred state.
|
|
257
|
+
const archive = await ops.backup(source);
|
|
258
|
+
if (existing) {
|
|
259
|
+
target = existing;
|
|
260
|
+
const targetStorage = ops.storage(target);
|
|
261
|
+
const targetWasRunning = await targetStorage.running();
|
|
262
|
+
try {
|
|
263
|
+
await ops.sleep(target);
|
|
264
|
+
if (await targetStorage.running()) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
"Target services are still running; teleport aborted.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const recovery = await ops.backup(target);
|
|
270
|
+
await ops.restore(target, archive, recovery);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (targetWasRunning && !(error instanceof TeleportRecoveryError)) {
|
|
273
|
+
await targetStorage.resume();
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
target = await ops.hatch(targetEnv, targetName, archive);
|
|
279
|
+
checkVersion(sourceVersion, await ops.version(target));
|
|
280
|
+
await startAndCheck(ops.storage(target));
|
|
281
|
+
}
|
|
282
|
+
ready = true;
|
|
283
|
+
setActiveAssistant(target.assistantId);
|
|
284
|
+
if (options.keepSource) {
|
|
285
|
+
console.log(
|
|
286
|
+
`Source '${source.assistantId}' retained, stopped, via --keep-source.`,
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
if (await sourceStorage.running()) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"Source was restarted during teleport. Destination is ready; source was retained to preserve newer writes.",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
await ops.retire(source);
|
|
295
|
+
}
|
|
296
|
+
pruneOldBackups(source.assistantId, 3, "pre-teleport");
|
|
297
|
+
if (existing) {
|
|
298
|
+
pruneOldBackups(existing.assistantId, 3, "pre-teleport");
|
|
299
|
+
}
|
|
300
|
+
console.log(
|
|
301
|
+
`Teleport complete: ${source.assistantId} → ${target.assistantId}`,
|
|
302
|
+
);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (!ready) {
|
|
305
|
+
if (target && !existing) {
|
|
306
|
+
await ops.sleep(target);
|
|
307
|
+
await ops.retire(target);
|
|
308
|
+
}
|
|
309
|
+
if (sourceWasRunning) {
|
|
310
|
+
await sourceStorage.resume();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const DockerConfiguration = z.object({
|
|
318
|
+
Id: z.string().optional(),
|
|
319
|
+
Image: z.string().min(1),
|
|
320
|
+
Config: z.object({ Env: z.array(z.string()), User: z.string() }),
|
|
321
|
+
HostConfig: z.object({ NetworkMode: z.string() }),
|
|
322
|
+
Mounts: z.array(
|
|
323
|
+
z.object({
|
|
324
|
+
Type: z.string(),
|
|
325
|
+
Name: z.string().optional(),
|
|
326
|
+
Destination: z.string(),
|
|
327
|
+
RW: z.boolean(),
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
/** Recreate standard CLI containers so immutable env cannot override imported identity. */
|
|
333
|
+
async function dockerReplacementOptions(
|
|
334
|
+
entry: AssistantEntry,
|
|
335
|
+
): Promise<Parameters<typeof startContainers>[0]> {
|
|
336
|
+
const res = dockerResourceNames(entry.assistantId);
|
|
337
|
+
const states = new Map<string, z.infer<typeof DockerConfiguration>>();
|
|
338
|
+
for (const container of [
|
|
339
|
+
res.assistantContainer,
|
|
340
|
+
res.gatewayContainer,
|
|
341
|
+
res.cesContainer,
|
|
342
|
+
]) {
|
|
343
|
+
const parsed = z
|
|
344
|
+
.array(DockerConfiguration)
|
|
345
|
+
.length(1)
|
|
346
|
+
.safeParse(
|
|
347
|
+
JSON.parse(await execOutput("docker", ["inspect", container])),
|
|
348
|
+
);
|
|
349
|
+
if (!parsed.success) {
|
|
350
|
+
throw new Error(`Cannot read Docker configuration for ${container}.`);
|
|
351
|
+
}
|
|
352
|
+
states.set(container, parsed.data[0]!);
|
|
353
|
+
}
|
|
354
|
+
const containerByService = {
|
|
355
|
+
assistant: res.assistantContainer,
|
|
356
|
+
gateway: res.gatewayContainer,
|
|
357
|
+
"credential-executor": res.cesContainer,
|
|
358
|
+
};
|
|
359
|
+
for (const spec of DOCKER_STATEFUL_SET_SPEC.containers) {
|
|
360
|
+
const state = states.get(containerByService[spec.internalName])!;
|
|
361
|
+
const expectedNetwork =
|
|
362
|
+
spec.network === "bridge"
|
|
363
|
+
? res.network
|
|
364
|
+
: `container:${res.assistantContainer}`;
|
|
365
|
+
const assistantId = states.get(res.assistantContainer)!.Id;
|
|
366
|
+
if (
|
|
367
|
+
state.HostConfig.NetworkMode !== expectedNetwork &&
|
|
368
|
+
!(
|
|
369
|
+
spec.network === "container" &&
|
|
370
|
+
assistantId &&
|
|
371
|
+
state.HostConfig.NetworkMode === `container:${assistantId}`
|
|
372
|
+
)
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
"Teleport into a customized Docker network requires a newly hatched target.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (state.Mounts.length !== spec.volumeMounts.length) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
"Teleport into customized Docker mounts requires a newly hatched target.",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
for (const mount of spec.volumeMounts) {
|
|
384
|
+
const volume = DOCKER_STATEFUL_SET_SPEC.volumeClaimTemplates.find(
|
|
385
|
+
(volume) => volume.name === mount.volumeName,
|
|
386
|
+
)!;
|
|
387
|
+
const found = state.Mounts.find(
|
|
388
|
+
(item) => item.Destination === mount.mountPath,
|
|
389
|
+
);
|
|
390
|
+
if (
|
|
391
|
+
!found ||
|
|
392
|
+
found.Type !== "volume" ||
|
|
393
|
+
found.Name !== volume.dockerVolume(entry.assistantId) ||
|
|
394
|
+
found.RW !== !mount.readOnly
|
|
395
|
+
) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
"Teleport into customized Docker mounts requires a newly hatched target.",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const env = (container: string) =>
|
|
403
|
+
Object.fromEntries(
|
|
404
|
+
states.get(container)!.Config.Env.map((value) => {
|
|
405
|
+
const index = value.indexOf("=");
|
|
406
|
+
return [value.slice(0, index), value.slice(index + 1)];
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
const replay = buildReplayState(
|
|
410
|
+
env(res.assistantContainer),
|
|
411
|
+
env(res.gatewayContainer),
|
|
412
|
+
);
|
|
413
|
+
const gatewayPort = Number(new URL(entry.runtimeUrl).port);
|
|
414
|
+
const assistantPort = entry.containerInfo?.assistantPort;
|
|
415
|
+
if (!Number.isSafeInteger(gatewayPort) || gatewayPort < 1 || !assistantPort) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
"Docker target is missing its published ports. Use a newly hatched target.",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
...replay,
|
|
422
|
+
gatewayPort,
|
|
423
|
+
assistantPort,
|
|
424
|
+
imageTags: {
|
|
425
|
+
assistant: states.get(res.assistantContainer)!.Image,
|
|
426
|
+
gateway: states.get(res.gatewayContainer)!.Image,
|
|
427
|
+
"credential-executor": states.get(res.cesContainer)!.Image,
|
|
428
|
+
},
|
|
429
|
+
instanceName: entry.assistantId,
|
|
430
|
+
res,
|
|
431
|
+
createOnly: true,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function restoreHostTeleportTarget(
|
|
436
|
+
entry: AssistantEntry,
|
|
437
|
+
archive: string,
|
|
438
|
+
recovery: string,
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
const stage = await stageRootArchive(archive);
|
|
441
|
+
await rm(stage, { recursive: true, force: true });
|
|
442
|
+
const previous =
|
|
443
|
+
entry.cloud === "docker"
|
|
444
|
+
? await dockerReplacementOptions(entry)
|
|
445
|
+
: undefined;
|
|
446
|
+
if (await storageFor(entry).running()) {
|
|
447
|
+
throw new Error("Target services must be stopped before teleport restore.");
|
|
448
|
+
}
|
|
449
|
+
const originalResources = entry.resources;
|
|
450
|
+
const replaceContainers = async (
|
|
451
|
+
opts: Parameters<typeof startContainers>[0],
|
|
452
|
+
) => {
|
|
453
|
+
const storage = new DockerRootStorage(entry);
|
|
454
|
+
if (await storage.running()) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
"Docker services must be stopped before container replacement.",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
for (const container of [
|
|
460
|
+
opts.res.assistantContainer,
|
|
461
|
+
opts.res.gatewayContainer,
|
|
462
|
+
opts.res.cesContainer,
|
|
463
|
+
]) {
|
|
464
|
+
await execOutput("docker", ["rm", container]);
|
|
465
|
+
}
|
|
466
|
+
await startContainers(opts, (message) => console.log(message));
|
|
467
|
+
};
|
|
468
|
+
try {
|
|
469
|
+
if (previous) {
|
|
470
|
+
await replaceContainers({ ...previous, signingKey: undefined });
|
|
471
|
+
}
|
|
472
|
+
await restoreRootBackup(entry, archive, { resume: false });
|
|
473
|
+
await startAndCheck(storageFor(entry));
|
|
474
|
+
} catch (error) {
|
|
475
|
+
try {
|
|
476
|
+
await defaultOps.sleep(entry);
|
|
477
|
+
if (previous) {
|
|
478
|
+
// Failed creation can leave an incomplete service group.
|
|
479
|
+
for (const container of [
|
|
480
|
+
previous.res.assistantContainer,
|
|
481
|
+
previous.res.gatewayContainer,
|
|
482
|
+
previous.res.cesContainer,
|
|
483
|
+
]) {
|
|
484
|
+
try {
|
|
485
|
+
await execOutput("docker", ["rm", container]);
|
|
486
|
+
} catch (removeError) {
|
|
487
|
+
if (
|
|
488
|
+
!String(removeError).toLowerCase().includes("no such container")
|
|
489
|
+
) {
|
|
490
|
+
throw removeError;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
await startContainers(previous, (message) => console.log(message));
|
|
495
|
+
}
|
|
496
|
+
entry.resources = originalResources;
|
|
497
|
+
await restoreRootBackup(entry, recovery, { resume: false });
|
|
498
|
+
} catch (recoveryError) {
|
|
499
|
+
throw new TeleportRecoveryError(
|
|
500
|
+
[error, recoveryError],
|
|
501
|
+
`Teleport target recovery failed; keep services stopped. Restore ${recovery}.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
package/src/lib/http-client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
2
3
|
|
|
3
4
|
export type HttpProbeEndpoint = "healthz" | "readyz";
|
|
@@ -141,3 +142,27 @@ export async function waitForDaemonMigrationsReady(
|
|
|
141
142
|
await new Promise((r) => setTimeout(r, 250));
|
|
142
143
|
}
|
|
143
144
|
}
|
|
145
|
+
|
|
146
|
+
const GatewayHealthSchema = z.object({ version: z.string().min(1).optional() });
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Best-effort fetch of the running service group version from the gateway
|
|
150
|
+
* `/healthz` endpoint. Returns `undefined` when the endpoint is
|
|
151
|
+
* unreachable or does not include a version field.
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchCurrentVersion(
|
|
154
|
+
runtimeUrl: string,
|
|
155
|
+
): Promise<string | undefined> {
|
|
156
|
+
try {
|
|
157
|
+
const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
|
|
158
|
+
signal: AbortSignal.timeout(5000),
|
|
159
|
+
});
|
|
160
|
+
if (resp.ok) {
|
|
161
|
+
const parsed = GatewayHealthSchema.safeParse(await resp.json());
|
|
162
|
+
return parsed.success ? parsed.data.version : undefined;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Best-effort
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* mint again rather than reuse what resolution returned.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
|
|
32
32
|
|
|
33
33
|
import { authHeaders } from "../cloud-client.js";
|
|
34
34
|
|
|
@@ -55,7 +55,7 @@ export class TunnelWsTokenError extends Error {
|
|
|
55
55
|
* reached: its tunnel is a loopback port that no naming convention predicts.
|
|
56
56
|
* Otherwise the deployment convention maps the cloud host onto its sibling
|
|
57
57
|
* (`cloud.mycadet.ai` to `tunnel.mycadet.ai`, `dev-cloud.mycadet.ai` to
|
|
58
|
-
* `tunnel
|
|
58
|
+
* `dev-tunnel.mycadet.ai`). A host outside that convention, including localhost,
|
|
59
59
|
* yields the production default, because guessing a tunnel for it would be
|
|
60
60
|
* worse than dialing the one host that certainly exists.
|
|
61
61
|
*/
|
|
@@ -66,7 +66,7 @@ export function tunnelHostForCloudUrl(cloudUrl: string): string {
|
|
|
66
66
|
}
|
|
67
67
|
try {
|
|
68
68
|
return (
|
|
69
|
-
|
|
69
|
+
tunnelHostForCloudHost(new URL(cloudUrl).host) ??
|
|
70
70
|
DEFAULT_TUNNEL_HOST
|
|
71
71
|
);
|
|
72
72
|
} catch {
|
package/src/lib/local.ts
CHANGED
|
@@ -76,10 +76,11 @@ import {
|
|
|
76
76
|
pathListDelimiter,
|
|
77
77
|
resolveProcessState,
|
|
78
78
|
stopProcessByPidFile,
|
|
79
|
+
stopProcessGracefully,
|
|
79
80
|
} from "./process.js";
|
|
80
81
|
import { stopTunnelProcess } from "./tunnel.js";
|
|
81
82
|
import { stripVersionPrefix } from "./version-compat.js";
|
|
82
|
-
import { openLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
83
|
+
import { openLogFile, openRuntimeLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
83
84
|
|
|
84
85
|
const _require = createRequire(import.meta.url);
|
|
85
86
|
|
|
@@ -839,7 +840,7 @@ async function startDaemonFromSource(
|
|
|
839
840
|
windowsHide: true,
|
|
840
841
|
})
|
|
841
842
|
: (() => {
|
|
842
|
-
const daemonLogFd =
|
|
843
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
843
844
|
const c = spawn(bunPath, ["run", resolve(daemonMainPath)], {
|
|
844
845
|
cwd: spawnStateRoot(),
|
|
845
846
|
detached: true,
|
|
@@ -912,7 +913,7 @@ async function startDaemonWatchFromSource(
|
|
|
912
913
|
// detect the in-progress spawn and wait instead of racing.
|
|
913
914
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
914
915
|
|
|
915
|
-
const daemonLogFd =
|
|
916
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
916
917
|
const child = spawn(
|
|
917
918
|
resolveBunExecutable(),
|
|
918
919
|
["--watch", "run", resolve(mainPath)],
|
|
@@ -1699,7 +1700,7 @@ export async function startLocalDaemon(
|
|
|
1699
1700
|
windowsHide: true,
|
|
1700
1701
|
})
|
|
1701
1702
|
: (() => {
|
|
1702
|
-
const daemonLogFd =
|
|
1703
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
1703
1704
|
const c = spawn(daemonBinary, [], {
|
|
1704
1705
|
cwd: spawnStateRoot(),
|
|
1705
1706
|
detached: true,
|
|
@@ -1905,7 +1906,7 @@ export async function startGateway(
|
|
|
1905
1906
|
// Use the compiled gateway binary when available (desktop app or compiled
|
|
1906
1907
|
// CLI invoked from the terminal). In watch mode, skip the bundled binary
|
|
1907
1908
|
// and use source (bun --watch only works with source files).
|
|
1908
|
-
const gatewayLogFd =
|
|
1909
|
+
const gatewayLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1909
1910
|
gateway = spawn(gatewayBinary, [], {
|
|
1910
1911
|
cwd: spawnStateRoot(),
|
|
1911
1912
|
detached: true,
|
|
@@ -1921,7 +1922,7 @@ export async function startGateway(
|
|
|
1921
1922
|
const bunArgs = watch
|
|
1922
1923
|
? ["--watch", "run", gatewayEntry, "--cadet-gateway"]
|
|
1923
1924
|
: ["run", gatewayEntry, "--cadet-gateway"];
|
|
1924
|
-
const gwLogFd =
|
|
1925
|
+
const gwLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1925
1926
|
gateway = spawn(resolveBunExecutable(), bunArgs, {
|
|
1926
1927
|
cwd: spawnStateRoot(),
|
|
1927
1928
|
detached: true,
|
|
@@ -1981,15 +1982,16 @@ export async function stopLocalProcesses(
|
|
|
1981
1982
|
"daemon",
|
|
1982
1983
|
undefined,
|
|
1983
1984
|
DAEMON_STOP_TIMEOUT_MS,
|
|
1985
|
+
stopProcessGracefully,
|
|
1984
1986
|
);
|
|
1985
1987
|
|
|
1986
1988
|
const gatewayPidFile = join(cadetDir, "gateway.pid");
|
|
1987
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1989
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000, stopProcessGracefully);
|
|
1988
1990
|
|
|
1989
1991
|
// Stop the CES sibling if one was launched. No-op when the
|
|
1990
1992
|
// PID file is absent.
|
|
1991
1993
|
const cesPidFile = join(cadetDir, "ces.pid");
|
|
1992
|
-
await stopProcessByPidFile(cesPidFile, "credential-executor");
|
|
1994
|
+
await stopProcessByPidFile(cesPidFile, "credential-executor", undefined, DAEMON_STOP_TIMEOUT_MS, stopProcessGracefully);
|
|
1993
1995
|
|
|
1994
1996
|
// Stop the tunnel agent this CLI spawned (root lockfile `tunnel` entry),
|
|
1995
1997
|
// verifying the pid still belongs to the provider's binary before signaling
|