@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e

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.
Files changed (111) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  17. package/src/__tests__/cloud-releases.test.ts +82 -0
  18. package/src/__tests__/commands-mock.ts +101 -0
  19. package/src/__tests__/debug.test.ts +17 -91
  20. package/src/__tests__/devices.test.ts +64 -2
  21. package/src/__tests__/ensure-binary.test.ts +52 -0
  22. package/src/__tests__/gateway-token.test.ts +267 -0
  23. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  24. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  25. package/src/__tests__/host-teleport.test.ts +405 -0
  26. package/src/__tests__/multi-local.test.ts +3 -3
  27. package/src/__tests__/nginx-ingress.test.ts +587 -36
  28. package/src/__tests__/orphan-detection.test.ts +1 -1
  29. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  30. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  31. package/src/__tests__/root-backup-docker.test.ts +294 -0
  32. package/src/__tests__/root-backup.test.ts +527 -0
  33. package/src/__tests__/root-restore-command.test.ts +147 -0
  34. package/src/__tests__/root-signing-key.test.ts +50 -0
  35. package/src/__tests__/setup.test.ts +17 -7
  36. package/src/__tests__/shell-mode.test.ts +83 -0
  37. package/src/__tests__/sleep.test.ts +11 -0
  38. package/src/__tests__/teleport.test.ts +68 -2996
  39. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  40. package/src/__tests__/tunnel-stop.test.ts +226 -0
  41. package/src/__tests__/tunnel.test.ts +22 -4
  42. package/src/__tests__/upgrade-local.test.ts +21 -11
  43. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  44. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  45. package/src/__tests__/wake.test.ts +4 -1
  46. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  47. package/src/commands/backup.ts +44 -399
  48. package/src/commands/client.ts +3 -3
  49. package/src/commands/completions.ts +0 -2
  50. package/src/commands/debug.ts +75 -131
  51. package/src/commands/devices.ts +117 -47
  52. package/src/commands/gateway/token.ts +77 -23
  53. package/src/commands/hatch.ts +10 -52
  54. package/src/commands/pair.ts +9 -3
  55. package/src/commands/ps.ts +4 -2
  56. package/src/commands/restore.ts +77 -444
  57. package/src/commands/retire.ts +2 -3
  58. package/src/commands/sleep.ts +15 -2
  59. package/src/commands/teleport.ts +47 -1559
  60. package/src/commands/tunnel.ts +91 -11
  61. package/src/commands/upgrade.ts +84 -157
  62. package/src/commands/wake.ts +1 -1
  63. package/src/index.ts +0 -4
  64. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  65. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  66. package/src/lib/__tests__/web-dist.test.ts +27 -1
  67. package/src/lib/assistant-client.ts +1 -1
  68. package/src/lib/assistant-config.ts +43 -23
  69. package/src/lib/backup-ops.ts +37 -251
  70. package/src/lib/cloud-client.ts +10 -13
  71. package/src/lib/cloud-login-poll.ts +75 -0
  72. package/src/lib/cloud-login.ts +5 -2
  73. package/src/lib/cloud-releases.ts +54 -10
  74. package/src/lib/cloud-values.ts +1 -1
  75. package/src/lib/confirm-action.ts +2 -2
  76. package/src/lib/docker.ts +65 -34
  77. package/src/lib/ensure-binary.ts +65 -6
  78. package/src/lib/github-repository.ts +2 -0
  79. package/src/lib/guardian-token.ts +1 -23
  80. package/src/lib/hatch-local.ts +18 -52
  81. package/src/lib/hatch-next-steps.ts +15 -2
  82. package/src/lib/host-teleport.ts +506 -0
  83. package/src/lib/http-client.ts +25 -0
  84. package/src/lib/ingress-config.ts +31 -0
  85. package/src/lib/live-voice/tunnel.test.ts +1 -1
  86. package/src/lib/live-voice/tunnel.ts +3 -3
  87. package/src/lib/local.ts +10 -8
  88. package/src/lib/nginx-ingress.ts +572 -95
  89. package/src/lib/process.ts +13 -0
  90. package/src/lib/root-backup.ts +917 -0
  91. package/src/lib/root-signing-key.ts +31 -0
  92. package/src/lib/runtime-url.ts +0 -29
  93. package/src/lib/teleport-backup.ts +3 -16
  94. package/src/lib/tunnel.ts +87 -34
  95. package/src/lib/tunnels/cadet.ts +12 -1
  96. package/src/lib/tunnels/cloudflare.ts +36 -1
  97. package/src/lib/tunnels/ngrok.ts +16 -2
  98. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  99. package/src/lib/tunnels/tailscale.ts +79 -1
  100. package/src/lib/upgrade-lifecycle.ts +63 -141
  101. package/src/lib/web-dist.ts +79 -1
  102. package/src/lib/xdg-log.ts +11 -0
  103. package/src/__tests__/bundle-staging.test.ts +0 -268
  104. package/src/__tests__/unpair.test.ts +0 -160
  105. package/src/commands/rollback.ts +0 -479
  106. package/src/commands/unpair.ts +0 -122
  107. package/src/lib/__tests__/job-polling.test.ts +0 -278
  108. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  109. package/src/lib/bundle-staging.ts +0 -220
  110. package/src/lib/job-polling.ts +0 -195
  111. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,3 +1,4 @@
1
+ import { persistRootSigningKey } from "./root-signing-key.js";
1
2
  import {
2
3
  existsSync,
3
4
  lstatSync,
@@ -52,7 +53,6 @@ import {
52
53
  import { logHatchNextSteps } from "./hatch-next-steps.js";
53
54
  import { printInstanceDetails } from "./instance-details.js";
54
55
  import { checkProviderApiKey } from "./api-key-check.js";
55
- import { loopbackSafeFetch } from "./loopback-fetch.js";
56
56
  import {
57
57
  describeLoginError,
58
58
  ensureCloudLoginInteractive,
@@ -156,13 +156,13 @@ export interface HatchLocalParams {
156
156
  name?: string | null;
157
157
  /** Build from a local source tree and hot-reload on change. */
158
158
  watch?: boolean;
159
- /** Stay attached after hatch and poll the gateway until it stops. */
160
- keepAlive?: boolean;
161
159
  /** Hatch-time config values (key → value). */
162
160
  configValues?: Record<string, string>;
163
161
  /** Extra env vars forwarded into the assistant process. */
164
162
  flagEnvVars?: Record<string, string>;
165
163
  setupProviderCredentials?: boolean;
164
+ /** Whole-installation state installed before the first service startup. */
165
+ initialArchive?: string;
166
166
  /**
167
167
  * Show the per-service startup logs (assistant, credential executor,
168
168
  * gateway) and the extended next steps. Off by default — the happy path
@@ -192,13 +192,7 @@ export interface HatchLocalResult {
192
192
  export async function hatchLocal(
193
193
  params: HatchLocalParams = {},
194
194
  ): Promise<HatchLocalResult> {
195
- const {
196
- name,
197
- watch = false,
198
- keepAlive = false,
199
- configValues = {},
200
- flagEnvVars = {},
201
- } = params;
195
+ const { name, watch = false, configValues = {}, flagEnvVars = {} } = params;
202
196
  const reporter = params.reporter ?? consoleLifecycleReporter;
203
197
  const verbose = params.verbose ?? false;
204
198
  const setupProviderCredentials = params.setupProviderCredentials !== false;
@@ -300,7 +294,20 @@ export async function hatchLocal(
300
294
  const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
301
295
 
302
296
  reporter.progress(3, 6, "Starting assistant...");
303
- const signingKey = generateLocalSigningKey();
297
+ const signingKey = params.initialArchive
298
+ ? await (
299
+ await import("./root-backup.js")
300
+ ).initializeRootArchive(
301
+ {
302
+ assistantId: instanceName,
303
+ cloud: "local",
304
+ runtimeUrl: "",
305
+ resources,
306
+ },
307
+ params.initialArchive,
308
+ )
309
+ : generateLocalSigningKey();
310
+ persistRootSigningKey(resources.instanceDir, signingKey);
304
311
  const bootstrapSecret = generateLocalSigningKey();
305
312
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
306
313
  try {
@@ -454,46 +461,5 @@ export async function hatchLocal(
454
461
  guardianAccessToken,
455
462
  };
456
463
 
457
- if (keepAlive) {
458
- const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
459
- const healthTarget = "Gateway";
460
- const POLL_INTERVAL_MS = 5000;
461
- const MAX_FAILURES = 3;
462
- let consecutiveFailures = 0;
463
-
464
- const shutdown = async (): Promise<void> => {
465
- reporter.log("\nShutting down local processes...");
466
- await stopLocalProcesses(resources);
467
- process.exit(0);
468
- };
469
-
470
- process.on("SIGTERM", () => void shutdown());
471
- process.on("SIGINT", () => void shutdown());
472
-
473
- // Poll the health endpoint until it stops responding.
474
- while (true) {
475
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
476
- try {
477
- const res = await loopbackSafeFetch(healthUrl, {
478
- signal: AbortSignal.timeout(3000),
479
- });
480
- if (res.ok) {
481
- consecutiveFailures = 0;
482
- } else {
483
- consecutiveFailures++;
484
- }
485
- } catch {
486
- consecutiveFailures++;
487
- }
488
- if (consecutiveFailures >= MAX_FAILURES) {
489
- reporter.log(
490
- `\n⚠️ ${healthTarget} stopped responding — shutting down.`,
491
- );
492
- await stopLocalProcesses(resources);
493
- process.exit(1);
494
- }
495
- }
496
- }
497
-
498
464
  return result;
499
465
  }
@@ -1,8 +1,21 @@
1
+ import { getCurrentEnvironment } from "./environments/resolve.js";
2
+
3
+ function cliCommand(): string {
4
+ // Production keeps the bare `cadet` binary; every non-production
5
+ // environment installs as `cadet-<env>` (the same rule as the config and
6
+ // state dirs — see environments/paths.ts).
7
+ const env = getCurrentEnvironment();
8
+ return env.name === "production" ? "cadet" : `cadet-${env.name}`;
9
+ }
10
+
1
11
  export function logHatchNextSteps(log: (message: string) => void): void {
12
+ const command = cliCommand();
2
13
  log("Next steps:");
3
- log(" - Run `cadet client` to interact with your cadet via the terminal");
4
14
  log(
5
- " - Run `cadet tunnel` to make your cadet publicly accessible and interact with it via the browser",
15
+ ` - Run \`${command} client\` to interact with your cadet via the terminal`,
16
+ );
17
+ log(
18
+ ` - Run \`${command} tunnel\` to make your cadet publicly accessible and interact with it via the browser`,
6
19
  );
7
20
  log("");
8
21
  }
@@ -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
+ }
@@ -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
+ }