@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,917 @@
|
|
|
1
|
+
import { persistRootSigningKey } from "./root-signing-key.js";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
chmod,
|
|
7
|
+
cp,
|
|
8
|
+
lstat,
|
|
9
|
+
mkdir,
|
|
10
|
+
mkdtemp,
|
|
11
|
+
readFile,
|
|
12
|
+
readdir,
|
|
13
|
+
rename,
|
|
14
|
+
rm,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
19
|
+
import { pipeline } from "node:stream/promises";
|
|
20
|
+
import * as tar from "tar";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { resolveExistingLocalSigningKey } from "@mycadet/local-mode";
|
|
23
|
+
import type { AssistantEntry } from "./assistant-config.js";
|
|
24
|
+
import { getDaemonPidPath, saveAssistantEntry } from "./assistant-config.js";
|
|
25
|
+
import { getBackupsDir } from "./backup-ops.js";
|
|
26
|
+
import { dockerResourceNames } from "./docker.js";
|
|
27
|
+
import { execOutput } from "./step-runner.js";
|
|
28
|
+
import { isProcessAlive } from "./process.js";
|
|
29
|
+
import { probePort } from "./port-probe.js";
|
|
30
|
+
import {
|
|
31
|
+
waitForDaemonReady,
|
|
32
|
+
waitForDaemonMigrationsReady,
|
|
33
|
+
} from "./http-client.js";
|
|
34
|
+
import { startCes, startGateway, startLocalDaemon } from "./local.js";
|
|
35
|
+
import {
|
|
36
|
+
ASSISTANT_INTERNAL_PORT,
|
|
37
|
+
GATEWAY_INTERNAL_PORT,
|
|
38
|
+
} from "./environments/paths.js";
|
|
39
|
+
import { sleepAssistant } from "../commands/sleep.js";
|
|
40
|
+
import { wakeAssistant } from "../commands/wake.js";
|
|
41
|
+
|
|
42
|
+
export const STATE_ROOTS = [
|
|
43
|
+
"workspace",
|
|
44
|
+
"gateway-security",
|
|
45
|
+
"ces-security",
|
|
46
|
+
"ces-data",
|
|
47
|
+
] as const;
|
|
48
|
+
type StateRoot = (typeof STATE_ROOTS)[number];
|
|
49
|
+
const ROOT_SET = new Set<string>(STATE_ROOTS);
|
|
50
|
+
const TRANSIENT_PATHS = new Set([
|
|
51
|
+
"workspace/cadet.pid",
|
|
52
|
+
"workspace/signals",
|
|
53
|
+
"workspace/data/ingress/nginx.pid",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function persistent(
|
|
57
|
+
path: string,
|
|
58
|
+
stat: import("node:fs").Stats | tar.ReadEntry,
|
|
59
|
+
): boolean {
|
|
60
|
+
return (
|
|
61
|
+
!TRANSIENT_PATHS.has(path) &&
|
|
62
|
+
("isFile" in stat
|
|
63
|
+
? stat.isFile() || stat.isDirectory() || stat.isSymbolicLink()
|
|
64
|
+
: ["File", "Directory", "SymbolicLink", "Link"].includes(stat.type))
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Plain tar root layout; no credential decoding or logical workspace transformation. */
|
|
69
|
+
export async function packRoot(root: string, output: string): Promise<void> {
|
|
70
|
+
await mkdir(dirname(resolve(output)), { recursive: true });
|
|
71
|
+
const partial = `${resolve(output)}.${randomUUID()}.partial`;
|
|
72
|
+
// Pre-create privately: tar must never briefly create a world-readable backup.
|
|
73
|
+
await writeFile(partial, "", { mode: 0o600, flag: "wx" });
|
|
74
|
+
try {
|
|
75
|
+
await tar.c(
|
|
76
|
+
{
|
|
77
|
+
cwd: root,
|
|
78
|
+
file: partial,
|
|
79
|
+
gzip: true,
|
|
80
|
+
strict: true,
|
|
81
|
+
portable: true,
|
|
82
|
+
filter: (path, stat) => persistent(path, stat),
|
|
83
|
+
},
|
|
84
|
+
[...STATE_ROOTS],
|
|
85
|
+
);
|
|
86
|
+
// Validate our own artifact before publishing it, including safe link topology.
|
|
87
|
+
await validateRootArchive(partial);
|
|
88
|
+
await rename(partial, resolve(output));
|
|
89
|
+
} finally {
|
|
90
|
+
await rm(partial, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function archivePath(path: string): string {
|
|
95
|
+
const stripped = path.replace(/^(\.\/)+/, "").replace(/\/$/, "");
|
|
96
|
+
if (
|
|
97
|
+
!stripped ||
|
|
98
|
+
path.includes("\\") ||
|
|
99
|
+
path.includes("\0") ||
|
|
100
|
+
path.startsWith("/") ||
|
|
101
|
+
/^[a-z]:/i.test(path) ||
|
|
102
|
+
stripped.split("/").some((p) => p === ".." || p === "." || !p) ||
|
|
103
|
+
!ROOT_SET.has(stripped.split("/")[0]!)
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(`Invalid root archive path: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
return stripped;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function validateRootArchive(file: string): Promise<void> {
|
|
111
|
+
if (/\.vbundle(?:\.enc)?$/i.test(file)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"Legacy .vbundle conversion belongs in vellum-to-cadet. Supply a root tar archive.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const entries = new Map<string, string>();
|
|
117
|
+
let invalid: Error | undefined;
|
|
118
|
+
await tar.t({
|
|
119
|
+
file,
|
|
120
|
+
strict: true,
|
|
121
|
+
onReadEntry: (entry) => {
|
|
122
|
+
// Defer rejection until the stream drains; never throw out of a stream callback.
|
|
123
|
+
try {
|
|
124
|
+
if (entry.path === "./" && entry.type === "Directory") return;
|
|
125
|
+
const path = archivePath(entry.path);
|
|
126
|
+
if (entries.has(path))
|
|
127
|
+
throw new Error(`Duplicate archive entry: ${path}`);
|
|
128
|
+
if (
|
|
129
|
+
!["File", "Directory", "SymbolicLink", "Link"].includes(entry.type)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(`Unsupported archive entry: ${path}`);
|
|
132
|
+
}
|
|
133
|
+
entries.set(path, entry.type);
|
|
134
|
+
if (entry.type === "SymbolicLink" || entry.type === "Link") {
|
|
135
|
+
const target = entry.linkpath ?? "";
|
|
136
|
+
if (
|
|
137
|
+
!target ||
|
|
138
|
+
target.startsWith("/") ||
|
|
139
|
+
target.includes("\\") ||
|
|
140
|
+
/^[a-z]:/i.test(target)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(`Unsafe archive link: ${path}`);
|
|
143
|
+
}
|
|
144
|
+
archivePath(
|
|
145
|
+
entry.type === "Link"
|
|
146
|
+
? target
|
|
147
|
+
: posix.normalize(posix.join(posix.dirname(path), target)),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
invalid ??= error instanceof Error ? error : new Error(String(error));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
if (invalid) throw invalid;
|
|
156
|
+
for (const root of STATE_ROOTS) {
|
|
157
|
+
if (entries.get(root) !== "Directory")
|
|
158
|
+
throw new Error(`Archive is missing state root: ${root}`);
|
|
159
|
+
}
|
|
160
|
+
for (const path of entries.keys()) {
|
|
161
|
+
let parent = posix.dirname(path);
|
|
162
|
+
while (parent !== ".") {
|
|
163
|
+
if (
|
|
164
|
+
entries.get(parent) === "SymbolicLink" ||
|
|
165
|
+
entries.get(parent) === "Link"
|
|
166
|
+
)
|
|
167
|
+
throw new Error(`Archive writes through a link: ${path}`);
|
|
168
|
+
parent = posix.dirname(parent);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function stageRootArchive(
|
|
174
|
+
file: string,
|
|
175
|
+
parent = tmpdir(),
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
if (/\.vbundle(?:\.enc)?$/i.test(file))
|
|
178
|
+
throw new Error(
|
|
179
|
+
"Convert legacy .vbundle backups with vellum-to-cadet before restoring.",
|
|
180
|
+
);
|
|
181
|
+
const stage = await mkdtemp(join(parent, ".cadet-restore-"));
|
|
182
|
+
await chmod(stage, 0o700);
|
|
183
|
+
try {
|
|
184
|
+
// Copy once so validation and extraction operate on identical bytes.
|
|
185
|
+
const input = join(stage, "input.tar");
|
|
186
|
+
await cp(resolve(file), input);
|
|
187
|
+
await chmod(input, 0o600);
|
|
188
|
+
await validateRootArchive(input);
|
|
189
|
+
const root = join(stage, "state");
|
|
190
|
+
await mkdir(root, { mode: 0o700 });
|
|
191
|
+
await tar.x({
|
|
192
|
+
file: input,
|
|
193
|
+
cwd: root,
|
|
194
|
+
strict: true,
|
|
195
|
+
preserveOwner: false,
|
|
196
|
+
noChmod: false,
|
|
197
|
+
});
|
|
198
|
+
await rm(input);
|
|
199
|
+
await pruneTransient(root);
|
|
200
|
+
return stage;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await rm(stage, { recursive: true, force: true });
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface RootStorage {
|
|
208
|
+
running(): Promise<boolean>;
|
|
209
|
+
capture(target: string): Promise<void>;
|
|
210
|
+
replace(source: string): Promise<void>;
|
|
211
|
+
prepareRestore?(source: string): Promise<void>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export class LocalRootStorage implements RootStorage {
|
|
215
|
+
readonly root: string;
|
|
216
|
+
initialRunning?: Set<string>;
|
|
217
|
+
constructor(readonly entry: AssistantEntry) {
|
|
218
|
+
if (!entry.resources)
|
|
219
|
+
throw new Error("Local backup requires the instance's resource paths.");
|
|
220
|
+
this.root = resolve(entry.resources.instanceDir);
|
|
221
|
+
}
|
|
222
|
+
async running(): Promise<boolean> {
|
|
223
|
+
const running = new Set(
|
|
224
|
+
[
|
|
225
|
+
["assistant", getDaemonPidPath(this.entry.resources)],
|
|
226
|
+
["gateway", join(this.root, "gateway.pid")],
|
|
227
|
+
["ces", join(this.root, "ces.pid")],
|
|
228
|
+
]
|
|
229
|
+
.filter(([, file]) => isProcessAlive(file!).alive)
|
|
230
|
+
.map(([service]) => service!),
|
|
231
|
+
);
|
|
232
|
+
const resources = this.entry.resources!;
|
|
233
|
+
for (const [service, port] of [
|
|
234
|
+
["assistant", resources.daemonPort],
|
|
235
|
+
["gateway", resources.gatewayPort],
|
|
236
|
+
["ces", resources.cesPort],
|
|
237
|
+
] as const) {
|
|
238
|
+
if (!running.has(service) && (await probePort(port))) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${service} port ${port} is occupied without a live PID file; stop that service before backup/restore.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.initialRunning ??= running;
|
|
245
|
+
return running.size > 0;
|
|
246
|
+
}
|
|
247
|
+
async resume(): Promise<void> {
|
|
248
|
+
if (this.initialRunning?.size === 3) {
|
|
249
|
+
await wakeAssistant(this.entry, {
|
|
250
|
+
watch: false,
|
|
251
|
+
foreground: false,
|
|
252
|
+
repairGuardian: false,
|
|
253
|
+
});
|
|
254
|
+
await this.checkReady();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const resources = this.entry.resources!;
|
|
258
|
+
const signingKey = resolveExistingLocalSigningKey(resources);
|
|
259
|
+
if (
|
|
260
|
+
this.initialRunning?.has("ces") &&
|
|
261
|
+
!isProcessAlive(join(this.root, "ces.pid")).alive
|
|
262
|
+
)
|
|
263
|
+
await startCes(false, resources);
|
|
264
|
+
if (
|
|
265
|
+
this.initialRunning?.has("assistant") &&
|
|
266
|
+
!isProcessAlive(getDaemonPidPath(resources)).alive
|
|
267
|
+
)
|
|
268
|
+
await startLocalDaemon(false, resources, { signingKey });
|
|
269
|
+
if (
|
|
270
|
+
this.initialRunning?.has("gateway") &&
|
|
271
|
+
!isProcessAlive(join(this.root, "gateway.pid")).alive
|
|
272
|
+
)
|
|
273
|
+
await startGateway(false, resources, {
|
|
274
|
+
signingKey,
|
|
275
|
+
bootstrapSecret: this.entry.guardianBootstrapSecret,
|
|
276
|
+
});
|
|
277
|
+
await this.checkReady();
|
|
278
|
+
}
|
|
279
|
+
private async checkReady(): Promise<void> {
|
|
280
|
+
const resources = this.entry.resources!;
|
|
281
|
+
if (
|
|
282
|
+
this.initialRunning?.has("assistant") &&
|
|
283
|
+
(await waitForDaemonMigrationsReady(
|
|
284
|
+
resources.daemonPort,
|
|
285
|
+
Date.now() + 60_000,
|
|
286
|
+
)) !== "ready"
|
|
287
|
+
) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"Assistant did not become ready after restoring its running state.",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
for (const [service, port] of [
|
|
293
|
+
["gateway", resources.gatewayPort],
|
|
294
|
+
["ces", resources.cesPort],
|
|
295
|
+
] as const) {
|
|
296
|
+
if (
|
|
297
|
+
this.initialRunning?.has(service) &&
|
|
298
|
+
!(await waitForDaemonReady(port))
|
|
299
|
+
)
|
|
300
|
+
throw new Error(`${service} did not become healthy after restart.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async capture(target: string): Promise<void> {
|
|
304
|
+
for (const root of STATE_ROOTS) {
|
|
305
|
+
const source = join(this.root, root);
|
|
306
|
+
const stat = await lstat(source).catch((error: NodeJS.ErrnoException) => {
|
|
307
|
+
if (error.code !== "ENOENT") throw error;
|
|
308
|
+
return undefined;
|
|
309
|
+
});
|
|
310
|
+
if (!stat) throw new Error(`Persistent state root is missing: ${root}`);
|
|
311
|
+
if (!stat.isDirectory())
|
|
312
|
+
throw new Error(`State root must be a directory: ${root}`);
|
|
313
|
+
await cp(source, join(target, root), {
|
|
314
|
+
recursive: true,
|
|
315
|
+
verbatimSymlinks: true,
|
|
316
|
+
filter: async (path) =>
|
|
317
|
+
persistent(
|
|
318
|
+
posix.join(
|
|
319
|
+
root,
|
|
320
|
+
path
|
|
321
|
+
.slice(source.length + 1)
|
|
322
|
+
.split("\\")
|
|
323
|
+
.join("/"),
|
|
324
|
+
),
|
|
325
|
+
await lstat(path),
|
|
326
|
+
),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// Local wake takes its signing key from the host registry. Make that identity
|
|
330
|
+
// part of the gateway root too, so the archive is self-contained.
|
|
331
|
+
const key = resolveExistingLocalSigningKey(this.entry.resources);
|
|
332
|
+
persistRootSigningKey(target, key);
|
|
333
|
+
}
|
|
334
|
+
async replace(source: string): Promise<void> {
|
|
335
|
+
// All renames stay on the instance's filesystem. A failed install rolls back
|
|
336
|
+
// every root, including the root whose incoming rename failed.
|
|
337
|
+
const work = await mkdtemp(join(dirname(this.root), ".cadet-install-"));
|
|
338
|
+
await chmod(work, 0o700);
|
|
339
|
+
const installed: StateRoot[] = [];
|
|
340
|
+
const saved: StateRoot[] = [];
|
|
341
|
+
let recovered = false;
|
|
342
|
+
try {
|
|
343
|
+
for (const root of STATE_ROOTS) {
|
|
344
|
+
await cp(join(source, root), join(work, `new-${root}`), {
|
|
345
|
+
recursive: true,
|
|
346
|
+
verbatimSymlinks: true,
|
|
347
|
+
});
|
|
348
|
+
await chmod(join(work, `new-${root}`), 0o700);
|
|
349
|
+
}
|
|
350
|
+
for (const root of STATE_ROOTS) {
|
|
351
|
+
if (
|
|
352
|
+
await lstat(join(this.root, root)).catch(
|
|
353
|
+
(error: NodeJS.ErrnoException) => {
|
|
354
|
+
if (error.code !== "ENOENT") throw error;
|
|
355
|
+
return undefined;
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
) {
|
|
359
|
+
await rename(join(this.root, root), join(work, `old-${root}`));
|
|
360
|
+
saved.push(root);
|
|
361
|
+
}
|
|
362
|
+
await rename(join(work, `new-${root}`), join(this.root, root));
|
|
363
|
+
installed.push(root);
|
|
364
|
+
}
|
|
365
|
+
recovered = true;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
try {
|
|
368
|
+
for (const root of installed.reverse())
|
|
369
|
+
await rm(join(this.root, root), { recursive: true, force: true });
|
|
370
|
+
for (const root of saved.reverse())
|
|
371
|
+
await rename(join(work, `old-${root}`), join(this.root, root));
|
|
372
|
+
recovered = true;
|
|
373
|
+
} catch (rollbackError) {
|
|
374
|
+
throw new AggregateError(
|
|
375
|
+
[error, rollbackError],
|
|
376
|
+
`Restore and rollback failed. Original roots are retained at ${work}; services must stay stopped.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
} finally {
|
|
381
|
+
if (recovered) await rm(work, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const ContainerInspectionSchema = z.object({
|
|
387
|
+
Image: z.string(),
|
|
388
|
+
State: z.object({ Status: z.string(), Running: z.boolean() }),
|
|
389
|
+
Config: z.object({ Env: z.array(z.string()), User: z.string() }),
|
|
390
|
+
Mounts: z.array(
|
|
391
|
+
z.object({
|
|
392
|
+
Type: z.string(),
|
|
393
|
+
Name: z.string().optional(),
|
|
394
|
+
Source: z.string(),
|
|
395
|
+
Destination: z.string(),
|
|
396
|
+
RW: z.boolean(),
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
399
|
+
});
|
|
400
|
+
type ContainerInspection = z.infer<typeof ContainerInspectionSchema>;
|
|
401
|
+
|
|
402
|
+
export interface DockerBackupOps {
|
|
403
|
+
output: typeof execOutput;
|
|
404
|
+
copy: (container: string, path: string, target: string) => Promise<void>;
|
|
405
|
+
install: (args: string[], source: string) => Promise<void>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const dockerOps: DockerBackupOps = {
|
|
409
|
+
output: execOutput,
|
|
410
|
+
copy: async (container, path, target) => {
|
|
411
|
+
await execOutput("docker", ["cp", `${container}:${path}/.`, target]);
|
|
412
|
+
},
|
|
413
|
+
install: async (args, source) => {
|
|
414
|
+
const child = spawn("docker", args, {
|
|
415
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
416
|
+
windowsHide: true,
|
|
417
|
+
});
|
|
418
|
+
let stderr = "";
|
|
419
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
420
|
+
stderr += chunk.toString();
|
|
421
|
+
});
|
|
422
|
+
const exited = new Promise<void>((done, fail) => {
|
|
423
|
+
child.once("error", fail);
|
|
424
|
+
child.once("close", (code) =>
|
|
425
|
+
code === 0
|
|
426
|
+
? done()
|
|
427
|
+
: fail(new Error(`Docker restore failed (${code}): ${stderr}`)),
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
const input = tar.c({ cwd: source, portable: true }, ["."]);
|
|
431
|
+
const transfer = pipeline(input, child.stdin).catch((error) => {
|
|
432
|
+
child.kill("SIGTERM");
|
|
433
|
+
throw error;
|
|
434
|
+
});
|
|
435
|
+
// Do not begin recovery while an earlier writer still owns the volume.
|
|
436
|
+
const results = await Promise.allSettled([transfer, exited]);
|
|
437
|
+
for (const result of results) {
|
|
438
|
+
if (result.status === "rejected") throw result.reason;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export class DockerRootStorage implements RootStorage {
|
|
444
|
+
readonly containers: Record<StateRoot, string>;
|
|
445
|
+
initialRunning?: Set<string>;
|
|
446
|
+
constructor(
|
|
447
|
+
readonly entry: AssistantEntry,
|
|
448
|
+
readonly ops: DockerBackupOps = dockerOps,
|
|
449
|
+
) {
|
|
450
|
+
const names = dockerResourceNames(entry.assistantId);
|
|
451
|
+
this.containers = {
|
|
452
|
+
workspace: names.assistantContainer,
|
|
453
|
+
"gateway-security": names.gatewayContainer,
|
|
454
|
+
"ces-security": names.cesContainer,
|
|
455
|
+
"ces-data": names.cesContainer,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
async inspect(container: string): Promise<ContainerInspection> {
|
|
459
|
+
const result = z
|
|
460
|
+
.array(ContainerInspectionSchema)
|
|
461
|
+
.length(1)
|
|
462
|
+
.safeParse(
|
|
463
|
+
JSON.parse(await this.ops.output("docker", ["inspect", container])),
|
|
464
|
+
);
|
|
465
|
+
if (!result.success) {
|
|
466
|
+
throw new Error(`Invalid container inspection: ${container}`);
|
|
467
|
+
}
|
|
468
|
+
return result.data[0]!;
|
|
469
|
+
}
|
|
470
|
+
async running(): Promise<boolean> {
|
|
471
|
+
const containers = [...new Set(Object.values(this.containers))];
|
|
472
|
+
const states = await Promise.all(
|
|
473
|
+
containers.map((container) => this.inspect(container)),
|
|
474
|
+
);
|
|
475
|
+
for (const state of states) {
|
|
476
|
+
if (!["created", "running", "exited"].includes(state.State.Status)) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Container is ${state.State.Status}; stop or repair it before backup/restore.`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
this.initialRunning ??= new Set(
|
|
483
|
+
containers.filter((_, index) => states[index]!.State.Running),
|
|
484
|
+
);
|
|
485
|
+
return states.some((state) => state.State.Running);
|
|
486
|
+
}
|
|
487
|
+
async resume(): Promise<void> {
|
|
488
|
+
if (this.initialRunning?.size === 3) {
|
|
489
|
+
await wakeAssistant(this.entry, {
|
|
490
|
+
watch: false,
|
|
491
|
+
foreground: false,
|
|
492
|
+
repairGuardian: false,
|
|
493
|
+
});
|
|
494
|
+
} else {
|
|
495
|
+
for (const container of new Set(Object.values(this.containers))) {
|
|
496
|
+
if (
|
|
497
|
+
this.initialRunning?.has(container) &&
|
|
498
|
+
!(await this.inspect(container)).State.Running
|
|
499
|
+
) {
|
|
500
|
+
await this.ops.output("docker", ["start", container]);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Probe inside the destination containers, so remote Docker contexts work
|
|
505
|
+
// without assuming their published ports are reachable on this CLI host.
|
|
506
|
+
const endpoints = [
|
|
507
|
+
[this.containers.workspace, ASSISTANT_INTERNAL_PORT, "readyz"],
|
|
508
|
+
[this.containers["gateway-security"], GATEWAY_INTERNAL_PORT, "healthz"],
|
|
509
|
+
[this.containers["ces-security"], 8090, "healthz"],
|
|
510
|
+
] as const;
|
|
511
|
+
await Promise.all(
|
|
512
|
+
endpoints.map(async ([container, defaultPort, endpoint]) => {
|
|
513
|
+
if (!this.initialRunning?.has(container)) return;
|
|
514
|
+
const state = await this.inspect(container);
|
|
515
|
+
const portVariable =
|
|
516
|
+
container === this.containers.workspace
|
|
517
|
+
? "RUNTIME_HTTP_PORT="
|
|
518
|
+
: "GATEWAY_PORT=";
|
|
519
|
+
const configuredPort =
|
|
520
|
+
container === this.containers["ces-security"]
|
|
521
|
+
? undefined
|
|
522
|
+
: state.Config.Env.find((value) =>
|
|
523
|
+
value.startsWith(portVariable),
|
|
524
|
+
)?.split("=")[1];
|
|
525
|
+
const port = configuredPort ? Number(configuredPort) : defaultPort;
|
|
526
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
527
|
+
throw new Error(`Invalid health port for ${container}.`);
|
|
528
|
+
const deadline = Date.now() + 60_000;
|
|
529
|
+
const script =
|
|
530
|
+
'const url=process.argv.at(-1); const r=await fetch(url,{signal:AbortSignal.timeout(1500)}); if(!r.ok)process.exit(1); if(url.endsWith("/readyz")){const b=await r.json(); if(b.dbMigrations?.state==="failed"){console.error("migration_failed");process.exit(2)} if(b.ready===false)process.exit(1)} console.log("ready")';
|
|
531
|
+
while (true) {
|
|
532
|
+
try {
|
|
533
|
+
await this.ops.output(
|
|
534
|
+
"docker",
|
|
535
|
+
[
|
|
536
|
+
"exec",
|
|
537
|
+
container,
|
|
538
|
+
"bun",
|
|
539
|
+
"--eval",
|
|
540
|
+
script,
|
|
541
|
+
`http://127.0.0.1:${port}/${endpoint}`,
|
|
542
|
+
],
|
|
543
|
+
{ timeoutMs: 3000 },
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (
|
|
548
|
+
String(error).includes("migration_failed") ||
|
|
549
|
+
Date.now() >= deadline
|
|
550
|
+
)
|
|
551
|
+
throw new Error(
|
|
552
|
+
`${container} did not become ready after restart.`,
|
|
553
|
+
{ cause: error },
|
|
554
|
+
);
|
|
555
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
async capture(target: string): Promise<void> {
|
|
562
|
+
for (const root of STATE_ROOTS) {
|
|
563
|
+
await mkdir(join(target, root), { mode: 0o700 });
|
|
564
|
+
await this.ops.copy(
|
|
565
|
+
this.containers[root],
|
|
566
|
+
`/${root}`,
|
|
567
|
+
join(target, root),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
// A Docker-injected key may never have been persisted by the gateway.
|
|
571
|
+
const gateway = await this.inspect(this.containers["gateway-security"]);
|
|
572
|
+
const key = gateway.Config.Env.find((value) =>
|
|
573
|
+
value.startsWith("ACTOR_TOKEN_SIGNING_KEY="),
|
|
574
|
+
)?.split("=")[1];
|
|
575
|
+
if (key) {
|
|
576
|
+
if (!/^[a-f0-9]{64}$/i.test(key))
|
|
577
|
+
throw new Error("Invalid injected gateway signing key.");
|
|
578
|
+
persistRootSigningKey(target, key);
|
|
579
|
+
}
|
|
580
|
+
// docker cp may retain sockets in its tar stream; remove only process artifacts.
|
|
581
|
+
await pruneTransient(target);
|
|
582
|
+
}
|
|
583
|
+
private async restoreMounts(source: string) {
|
|
584
|
+
const key = await readFile(
|
|
585
|
+
join(source, "gateway-security", "actor-token-signing-key"),
|
|
586
|
+
);
|
|
587
|
+
const inspections = new Map<string, ContainerInspection>();
|
|
588
|
+
for (const container of new Set(Object.values(this.containers)))
|
|
589
|
+
inspections.set(container, await this.inspect(container));
|
|
590
|
+
// Container env is immutable. Refuse before changing any volume if it would
|
|
591
|
+
// override the restored identity. Teleport's provisioning step creates new
|
|
592
|
+
// containers against the restored roots instead.
|
|
593
|
+
for (const state of inspections.values()) {
|
|
594
|
+
const injected = state.Config.Env.find((value) =>
|
|
595
|
+
value.startsWith("ACTOR_TOKEN_SIGNING_KEY="),
|
|
596
|
+
)?.slice("ACTOR_TOKEN_SIGNING_KEY=".length);
|
|
597
|
+
if (injected && injected.toLowerCase() !== key.toString("hex")) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
"Destination containers inject a different signing key. Recreate them without ACTOR_TOKEN_SIGNING_KEY before restoring this installation.",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return STATE_ROOTS.map((root) => {
|
|
604
|
+
const state = inspections.get(this.containers[root])!;
|
|
605
|
+
const mount = state.Mounts.find(
|
|
606
|
+
(item) => item.Destination === `/${root}`,
|
|
607
|
+
);
|
|
608
|
+
if (!mount?.RW || !["volume", "bind"].includes(mount.Type))
|
|
609
|
+
throw new Error(`No writable persistent mount for ${root}.`);
|
|
610
|
+
const source = mount.Type === "volume" ? mount.Name : mount.Source;
|
|
611
|
+
if (!source || source.includes(","))
|
|
612
|
+
throw new Error(`Unsupported mount source for ${root}.`);
|
|
613
|
+
// Ownership is destination-owned, never the uid from the archive/host.
|
|
614
|
+
const user = state.Config.User || "1001:1001";
|
|
615
|
+
const owner =
|
|
616
|
+
root === "workspace"
|
|
617
|
+
? "1001:1001"
|
|
618
|
+
: user.includes(":")
|
|
619
|
+
? user
|
|
620
|
+
: `${user}:${user}`;
|
|
621
|
+
if (!/^[\w.-]+(?::[\w.-]+)?$/.test(owner))
|
|
622
|
+
throw new Error(`Invalid destination user for ${root}.`);
|
|
623
|
+
return {
|
|
624
|
+
root,
|
|
625
|
+
state,
|
|
626
|
+
owner,
|
|
627
|
+
mount: `type=${mount.Type},src=${source},dst=/restore`,
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async prepareRestore(source: string): Promise<void> {
|
|
632
|
+
await this.restoreMounts(source);
|
|
633
|
+
}
|
|
634
|
+
async replace(source: string): Promise<void> {
|
|
635
|
+
const mounts = await this.restoreMounts(source);
|
|
636
|
+
for (const { root, state, mount, owner } of mounts) {
|
|
637
|
+
await this.ops.install(
|
|
638
|
+
[
|
|
639
|
+
"run",
|
|
640
|
+
"--rm",
|
|
641
|
+
"-i",
|
|
642
|
+
"--network=none",
|
|
643
|
+
"--user=0",
|
|
644
|
+
"--mount",
|
|
645
|
+
mount,
|
|
646
|
+
"--entrypoint=/bin/sh",
|
|
647
|
+
state.Image,
|
|
648
|
+
"-ec",
|
|
649
|
+
'find /restore -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; tar -xpf - -C /restore; chown -R "$1" /restore; chmod 700 /restore',
|
|
650
|
+
"cadet-restore",
|
|
651
|
+
owner,
|
|
652
|
+
],
|
|
653
|
+
join(source, root),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function pruneTransient(root: string, relative = ""): Promise<void> {
|
|
660
|
+
for (const name of await readdir(join(root, relative))) {
|
|
661
|
+
const path = posix.join(relative, name);
|
|
662
|
+
const stat = await lstat(join(root, path));
|
|
663
|
+
if (!persistent(path, stat))
|
|
664
|
+
await rm(join(root, path), { recursive: true, force: true });
|
|
665
|
+
else if (stat.isDirectory()) await pruneTransient(root, path);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export interface RootBackupOps {
|
|
670
|
+
storage: RootStorage;
|
|
671
|
+
sleep: () => Promise<void>;
|
|
672
|
+
wake: () => Promise<void>;
|
|
673
|
+
save: (entry: AssistantEntry) => void;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function defaultOps(entry: AssistantEntry): RootBackupOps {
|
|
677
|
+
const storage =
|
|
678
|
+
entry.cloud === "docker"
|
|
679
|
+
? new DockerRootStorage(entry)
|
|
680
|
+
: new LocalRootStorage(entry);
|
|
681
|
+
return {
|
|
682
|
+
storage,
|
|
683
|
+
sleep: () =>
|
|
684
|
+
sleepAssistant(entry, {
|
|
685
|
+
force: false,
|
|
686
|
+
wait: false,
|
|
687
|
+
waitMs: null,
|
|
688
|
+
}),
|
|
689
|
+
wake: () => storage.resume(),
|
|
690
|
+
save: saveAssistantEntry,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function assertStopped(storage: RootStorage): Promise<void> {
|
|
695
|
+
if (await storage.running())
|
|
696
|
+
throw new Error(
|
|
697
|
+
"Services are still running; no consistent root capture is possible.",
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const ownedOperationLocks = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
702
|
+
|
|
703
|
+
/** Hold storage ownership across lifecycle steps; nested backup calls share the lock. */
|
|
704
|
+
export async function withRootOperationLock<T>(
|
|
705
|
+
entry: AssistantEntry,
|
|
706
|
+
work: () => Promise<T>,
|
|
707
|
+
): Promise<T> {
|
|
708
|
+
const parent = join(getBackupsDir(), ".operations");
|
|
709
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
710
|
+
const identity =
|
|
711
|
+
entry.cloud === "docker"
|
|
712
|
+
? `${process.env.DOCKER_HOST ?? ""}:${process.env.DOCKER_CONTEXT ?? ""}:${entry.assistantId}`
|
|
713
|
+
: entry.resources?.instanceDir;
|
|
714
|
+
const lock = join(
|
|
715
|
+
parent,
|
|
716
|
+
createHash("sha256")
|
|
717
|
+
.update(identity ?? entry.assistantId)
|
|
718
|
+
.digest("hex"),
|
|
719
|
+
);
|
|
720
|
+
const owned = ownedOperationLocks.getStore();
|
|
721
|
+
if (owned?.has(lock)) {
|
|
722
|
+
return work();
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
await mkdir(lock, { mode: 0o700 });
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST")
|
|
728
|
+
throw new Error(
|
|
729
|
+
`Another backup/restore owns ${lock}. If its process has exited, remove that operation lock and retry.`,
|
|
730
|
+
);
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
return await ownedOperationLocks.run(
|
|
735
|
+
new Set([...(owned ?? []), lock]),
|
|
736
|
+
work,
|
|
737
|
+
);
|
|
738
|
+
} finally {
|
|
739
|
+
await rm(lock, { recursive: true, force: true });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export async function createRootBackup(
|
|
744
|
+
entry: AssistantEntry,
|
|
745
|
+
output: string,
|
|
746
|
+
ops = defaultOps(entry),
|
|
747
|
+
): Promise<void> {
|
|
748
|
+
return withRootOperationLock(entry, () => captureBackup(entry, output, ops));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function captureBackup(
|
|
752
|
+
entry: AssistantEntry,
|
|
753
|
+
output: string,
|
|
754
|
+
ops: RootBackupOps,
|
|
755
|
+
): Promise<void> {
|
|
756
|
+
if (entry.cloud !== "docker" && entry.resources) {
|
|
757
|
+
for (const root of STATE_ROOTS) {
|
|
758
|
+
const path = resolve(entry.resources.instanceDir, root);
|
|
759
|
+
if (resolve(output) === path || resolve(output).startsWith(`${path}/`))
|
|
760
|
+
throw new Error("Save backups outside the persistent service roots.");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const stage = await mkdtemp(join(tmpdir(), "cadet-backup-"));
|
|
764
|
+
await chmod(stage, 0o700);
|
|
765
|
+
let running = false;
|
|
766
|
+
let stopped = false;
|
|
767
|
+
try {
|
|
768
|
+
running = await ops.storage.running();
|
|
769
|
+
stopped = running;
|
|
770
|
+
if (running) await ops.sleep();
|
|
771
|
+
stopped = true;
|
|
772
|
+
await assertStopped(ops.storage);
|
|
773
|
+
await ops.storage.capture(stage);
|
|
774
|
+
await assertStopped(ops.storage);
|
|
775
|
+
if (running) {
|
|
776
|
+
await ops.wake();
|
|
777
|
+
stopped = false;
|
|
778
|
+
}
|
|
779
|
+
await packRoot(stage, output);
|
|
780
|
+
} finally {
|
|
781
|
+
try {
|
|
782
|
+
if (running && stopped) await ops.wake();
|
|
783
|
+
} finally {
|
|
784
|
+
await rm(stage, { recursive: true, force: true });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export async function restoreRootBackup(
|
|
790
|
+
entry: AssistantEntry,
|
|
791
|
+
file: string,
|
|
792
|
+
options: { dryRun?: boolean; recoveryPath?: string; resume?: boolean } = {},
|
|
793
|
+
ops = defaultOps(entry),
|
|
794
|
+
): Promise<string | undefined> {
|
|
795
|
+
return withRootOperationLock(entry, () =>
|
|
796
|
+
restoreBackup(entry, file, options, ops),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Install into a newly allocated instance before any service starts. */
|
|
801
|
+
export async function initializeRootArchive(
|
|
802
|
+
entry: AssistantEntry,
|
|
803
|
+
file: string,
|
|
804
|
+
storage: RootStorage = entry.cloud === "docker"
|
|
805
|
+
? new DockerRootStorage(entry)
|
|
806
|
+
: new LocalRootStorage(entry),
|
|
807
|
+
): Promise<string> {
|
|
808
|
+
return withRootOperationLock(entry, async () => {
|
|
809
|
+
const stage = await stageRootArchive(file);
|
|
810
|
+
try {
|
|
811
|
+
const source = join(stage, "state");
|
|
812
|
+
const signingKey = resolveExistingLocalSigningKey({
|
|
813
|
+
instanceDir: source,
|
|
814
|
+
});
|
|
815
|
+
await storage.prepareRestore?.(source);
|
|
816
|
+
await assertStopped(storage);
|
|
817
|
+
await storage.replace(source);
|
|
818
|
+
return signingKey;
|
|
819
|
+
} finally {
|
|
820
|
+
await rm(stage, { recursive: true, force: true });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function restoreBackup(
|
|
826
|
+
entry: AssistantEntry,
|
|
827
|
+
file: string,
|
|
828
|
+
options: { dryRun?: boolean; recoveryPath?: string; resume?: boolean },
|
|
829
|
+
ops: RootBackupOps,
|
|
830
|
+
): Promise<string | undefined> {
|
|
831
|
+
const stage = await stageRootArchive(file);
|
|
832
|
+
const source = join(stage, "state");
|
|
833
|
+
const snapshot = join(stage, "previous");
|
|
834
|
+
let stopped = false;
|
|
835
|
+
let replacing = false;
|
|
836
|
+
let restored = false;
|
|
837
|
+
let running = false;
|
|
838
|
+
let recoveryPath: string | undefined;
|
|
839
|
+
const oldResources = entry.resources;
|
|
840
|
+
try {
|
|
841
|
+
// Read only gateway identity material; CES ciphertext is never interpreted.
|
|
842
|
+
const signingKey = resolveExistingLocalSigningKey({ instanceDir: source });
|
|
843
|
+
await ops.storage.prepareRestore?.(source);
|
|
844
|
+
if (options.dryRun) return undefined;
|
|
845
|
+
running = await ops.storage.running();
|
|
846
|
+
stopped = running;
|
|
847
|
+
if (running) await ops.sleep();
|
|
848
|
+
stopped = true;
|
|
849
|
+
await assertStopped(ops.storage);
|
|
850
|
+
await mkdir(snapshot, { mode: 0o700 });
|
|
851
|
+
await ops.storage.capture(snapshot);
|
|
852
|
+
recoveryPath =
|
|
853
|
+
options.recoveryPath ??
|
|
854
|
+
join(
|
|
855
|
+
getBackupsDir(),
|
|
856
|
+
`${entry.assistantId}-pre-restore-${Date.now()}.tar.gz`,
|
|
857
|
+
);
|
|
858
|
+
await packRoot(snapshot, recoveryPath);
|
|
859
|
+
await assertStopped(ops.storage);
|
|
860
|
+
replacing = true;
|
|
861
|
+
try {
|
|
862
|
+
await ops.storage.replace(source);
|
|
863
|
+
if (entry.cloud !== "docker" && entry.resources) {
|
|
864
|
+
entry.resources = { ...entry.resources, signingKey };
|
|
865
|
+
ops.save(entry);
|
|
866
|
+
}
|
|
867
|
+
restored = true;
|
|
868
|
+
} catch (error) {
|
|
869
|
+
try {
|
|
870
|
+
await ops.storage.replace(snapshot);
|
|
871
|
+
entry.resources = oldResources;
|
|
872
|
+
ops.save(entry);
|
|
873
|
+
replacing = false;
|
|
874
|
+
} catch (rollbackError) {
|
|
875
|
+
throw new AggregateError(
|
|
876
|
+
[error, rollbackError],
|
|
877
|
+
`Restore failed and could not recover. Services remain stopped; recovery archive: ${recoveryPath}`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
throw error;
|
|
881
|
+
}
|
|
882
|
+
if (running && options.resume !== false) {
|
|
883
|
+
try {
|
|
884
|
+
await ops.wake();
|
|
885
|
+
stopped = false;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
try {
|
|
888
|
+
await ops.sleep();
|
|
889
|
+
} catch (stopError) {
|
|
890
|
+
throw new AggregateError(
|
|
891
|
+
[error, stopError],
|
|
892
|
+
`Restored state could not start or stop cleanly. Recovery archive: ${recoveryPath}`,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
throw new Error(
|
|
896
|
+
`Restored state could not start; services were stopped. Recovery archive: ${recoveryPath}`,
|
|
897
|
+
{ cause: error },
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return recoveryPath;
|
|
902
|
+
} finally {
|
|
903
|
+
try {
|
|
904
|
+
// A failed replacement/failed start must never boot partially restored state.
|
|
905
|
+
if (
|
|
906
|
+
running &&
|
|
907
|
+
stopped &&
|
|
908
|
+
!replacing &&
|
|
909
|
+
!restored &&
|
|
910
|
+
options.resume !== false
|
|
911
|
+
)
|
|
912
|
+
await ops.wake();
|
|
913
|
+
} finally {
|
|
914
|
+
await rm(stage, { recursive: true, force: true });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|