@cotal-ai/cli 0.11.6 → 0.12.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/dist/commands/backup.d.ts +13 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +443 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/channels.d.ts +10 -0
- package/dist/commands/channels.d.ts.map +1 -1
- package/dist/commands/channels.js.map +1 -1
- package/dist/commands/clean.d.ts +12 -2
- package/dist/commands/clean.d.ts.map +1 -1
- package/dist/commands/clean.js +161 -28
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/down.d.ts +5 -0
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +323 -5
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/spawn.d.ts +4 -0
- package/dist/commands/spawn.d.ts.map +1 -1
- package/dist/commands/spawn.js +43 -23
- package/dist/commands/spawn.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +3 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/up.d.ts +12 -0
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +1149 -193
- package/dist/commands/up.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/backup-artifact.d.ts +50 -0
- package/dist/lib/backup-artifact.d.ts.map +1 -0
- package/dist/lib/backup-artifact.js +290 -0
- package/dist/lib/backup-artifact.js.map +1 -0
- package/dist/lib/delivery-proc.d.ts +7 -2
- package/dist/lib/delivery-proc.d.ts.map +1 -1
- package/dist/lib/delivery-proc.js +29 -19
- package/dist/lib/delivery-proc.js.map +1 -1
- package/dist/lib/endpoint-cut.d.ts +5 -0
- package/dist/lib/endpoint-cut.d.ts.map +1 -0
- package/dist/lib/endpoint-cut.js +53 -0
- package/dist/lib/endpoint-cut.js.map +1 -0
- package/dist/lib/isolated-broker.d.ts +70 -0
- package/dist/lib/isolated-broker.d.ts.map +1 -0
- package/dist/lib/isolated-broker.js +427 -0
- package/dist/lib/isolated-broker.js.map +1 -0
- package/dist/lib/maintenance-files.d.ts +10 -0
- package/dist/lib/maintenance-files.d.ts.map +1 -0
- package/dist/lib/maintenance-files.js +60 -0
- package/dist/lib/maintenance-files.js.map +1 -0
- package/dist/lib/manager-proc.d.ts +4 -0
- package/dist/lib/manager-proc.d.ts.map +1 -1
- package/dist/lib/manager-proc.js +2 -0
- package/dist/lib/manager-proc.js.map +1 -1
- package/dist/lib/restore.d.ts +52 -0
- package/dist/lib/restore.d.ts.map +1 -0
- package/dist/lib/restore.js +797 -0
- package/dist/lib/restore.js.map +1 -0
- package/package.json +7 -4
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, realpathSync, readSync, closeSync, rmSync, writeSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { jetstreamManager } from "@nats-io/jetstream";
|
|
6
|
+
import { Kvm } from "@nats-io/kv";
|
|
7
|
+
import { canonicalBackupStreamConfig, consumerConfigFromCheckpoint, deliveryBucket, downloadStreamSnapshot, finalizeStreamRestore, initiateStreamRestore, LEASE_TTL_MS, managerBucket, MANAGER_LEASE_TTL_MS, membershipBucket, MEMBERSHIP_MAX_BYTES, presenceBucket, recreateConsumerCheckpoint, spaceBackupInventory, uploadStreamRestoreChunk, validateSpaceAuth, validateSpaceBackupInventory, validateBackupStreamState, validateCanonicalBackupStreamConfig, } from "@cotal-ai/core";
|
|
8
|
+
import { acquireMaintenanceLock, assessRestoreClaim, authDir, bindRestoreListener, bindRestoreTarget, loadSpaceAuth, markRestoreActive, markRestoreDegraded, moveSamePathRestoreSource, prepareAlternateRestore, prepareMissingSourceRestore, prepareSamePathRestore, readMaintenanceJournal, readMaintenanceResumeDocument, recordRestoreAttemptResources, recordRestoreManagerCommit, replaceDeadRestoreListener, repairRestoreDegradedToActive, releaseMaintenanceLock, rollbackRestore, writeRestoreCommitIntent, } from "@cotal-ai/workspace";
|
|
9
|
+
import { readStagedCheckpoints, stageArtifact } from "./backup-artifact.js";
|
|
10
|
+
import { authorityFingerprint } from "./maintenance-files.js";
|
|
11
|
+
import { connectIsolatedBroker, ensurePrivateAttemptsDir, startIsolatedBroker, sweepAttemptResidue } from "./isolated-broker.js";
|
|
12
|
+
const RESTORE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
const CHUNK_BYTES = 1024 * 1024;
|
|
14
|
+
function launchString(launch, key) {
|
|
15
|
+
const value = launch[key];
|
|
16
|
+
if (typeof value !== "string" || !value)
|
|
17
|
+
throw new Error(`restore launch record is missing ${key}`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function restoreServerIdentity(attemptId, serverName, serverNonce) {
|
|
21
|
+
if (!/^[0-9a-f]{32}$/.test(serverNonce) || serverName !== `${attemptId}-${serverNonce}`)
|
|
22
|
+
throw new Error(`restore attempt ${attemptId} has invalid listener name/nonce provenance`);
|
|
23
|
+
}
|
|
24
|
+
/** Remove only journaled attempt-owned working trees whose inode identity still matches. */
|
|
25
|
+
function removeRecordedOwnedPaths(ownedPaths) {
|
|
26
|
+
for (const owned of ownedPaths ?? []) {
|
|
27
|
+
try {
|
|
28
|
+
const stat = lstatSync(owned.path, { bigint: true });
|
|
29
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() &&
|
|
30
|
+
stat.dev.toString() === owned.dev && stat.ino.toString() === owned.ino)
|
|
31
|
+
rmSync(owned.path, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
catch { /* absent or identity cannot be proven — preserve */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function rehydratePreparedRestore(root, journal) {
|
|
37
|
+
const resume = readMaintenanceResumeDocument(root, journal.resume);
|
|
38
|
+
const restoreOnly = launchString(journal.launch, "restoreOnly");
|
|
39
|
+
if (restoreOnly !== "full" && restoreOnly !== "registry")
|
|
40
|
+
throw new Error(`unsupported restored selection ${restoreOnly}`);
|
|
41
|
+
const replacementPending = !journal.listenerProof && Boolean(journal.listenerReplacements?.length);
|
|
42
|
+
const serverNonce = journal.listenerProof?.serverNonce ??
|
|
43
|
+
(replacementPending ? randomUUID().replaceAll("-", "") : launchString(journal.launch, "serverNonce"));
|
|
44
|
+
const serverName = journal.listenerProof?.serverName ??
|
|
45
|
+
(replacementPending ? `${journal.restore.attemptId}-${serverNonce}` : launchString(journal.launch, "serverName"));
|
|
46
|
+
restoreServerIdentity(journal.restore.attemptId, serverName, serverNonce);
|
|
47
|
+
return {
|
|
48
|
+
root,
|
|
49
|
+
attemptId: journal.restore.attemptId,
|
|
50
|
+
targetPath: journal.restore.target.path,
|
|
51
|
+
space: journal.space,
|
|
52
|
+
mode: journal.mode,
|
|
53
|
+
server: launchString(journal.launch, "server"),
|
|
54
|
+
host: launchString(journal.launch, "host"),
|
|
55
|
+
runtime: launchString(journal.launch, "runtime"),
|
|
56
|
+
detached: journal.launch.detached === true,
|
|
57
|
+
selection: restoreOnly,
|
|
58
|
+
inventory: resume.inventory,
|
|
59
|
+
serverName,
|
|
60
|
+
serverNonce,
|
|
61
|
+
reentry: !replacementPending,
|
|
62
|
+
journalState: journal.state,
|
|
63
|
+
...(journal.state !== "commit-intent" && journal.managerCommit ? { managerCommit: journal.managerCommit } : {}),
|
|
64
|
+
...(journal.listenerProof ? { listenerProof: journal.listenerProof } : {}),
|
|
65
|
+
cleanupStage() {
|
|
66
|
+
removeRecordedOwnedPaths(journal.restore.ownedPaths);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function bindPreparedRestoreListener(prepared, processOwner) {
|
|
71
|
+
const lock = acquireMaintenanceLock(prepared.root);
|
|
72
|
+
try {
|
|
73
|
+
const journal = readMaintenanceJournal(prepared.root);
|
|
74
|
+
if (!journal || journal.state !== "commit-intent" || journal.restore.attemptId !== prepared.attemptId)
|
|
75
|
+
throw new Error(`restore listener bind does not match commit attempt ${prepared.attemptId}`);
|
|
76
|
+
const proof = {
|
|
77
|
+
attemptId: prepared.attemptId,
|
|
78
|
+
serverName: prepared.serverName,
|
|
79
|
+
serverNonce: prepared.serverNonce,
|
|
80
|
+
processOwner,
|
|
81
|
+
serverEndpoint: prepared.server,
|
|
82
|
+
target: journal.restore.target,
|
|
83
|
+
};
|
|
84
|
+
bindRestoreListener(lock, proof);
|
|
85
|
+
prepared.listenerProof = proof;
|
|
86
|
+
return proof;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
releaseMaintenanceLock(lock);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function replacePreparedDeadRestoreListener(prepared) {
|
|
93
|
+
if (!prepared.listenerProof)
|
|
94
|
+
throw new Error(`restore attempt ${prepared.attemptId} has no bound listener proof to replace`);
|
|
95
|
+
const lock = acquireMaintenanceLock(prepared.root);
|
|
96
|
+
try {
|
|
97
|
+
replaceDeadRestoreListener(lock, prepared.listenerProof);
|
|
98
|
+
prepared.serverNonce = randomUUID().replaceAll("-", "");
|
|
99
|
+
prepared.serverName = `${prepared.attemptId}-${prepared.serverNonce}`;
|
|
100
|
+
prepared.listenerProof = undefined;
|
|
101
|
+
prepared.journalState = "commit-intent";
|
|
102
|
+
prepared.reentry = false;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
releaseMaintenanceLock(lock);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function isManagerCommittedRestore(journal) {
|
|
109
|
+
return isManagerCommitResult(journal.managerCommit, journal.restore.attemptId) &&
|
|
110
|
+
isManagerFinalizeEvidence(journal.details.managerFinalize, journal.restore.attemptId, journal.managerCommit.durableCommitToken);
|
|
111
|
+
}
|
|
112
|
+
export function isManagerCommitResult(value, attemptId) {
|
|
113
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) &&
|
|
114
|
+
JSON.stringify(Object.keys(value).sort()) ===
|
|
115
|
+
JSON.stringify(["attemptId", "durableCommitToken", "state"].sort()) &&
|
|
116
|
+
value.attemptId === attemptId &&
|
|
117
|
+
value.state === "awaitingFinalize" &&
|
|
118
|
+
typeof value.durableCommitToken === "string" &&
|
|
119
|
+
/^[a-f0-9]{64}$/.test(value.durableCommitToken));
|
|
120
|
+
}
|
|
121
|
+
export function isManagerFinalizeResult(value, attemptId) {
|
|
122
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) &&
|
|
123
|
+
JSON.stringify(Object.keys(value).sort()) === JSON.stringify(["attemptId", "state"].sort()) &&
|
|
124
|
+
value.attemptId === attemptId &&
|
|
125
|
+
value.state === "active");
|
|
126
|
+
}
|
|
127
|
+
function isManagerFinalizeEvidence(value, attemptId, durableCommitToken) {
|
|
128
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) &&
|
|
129
|
+
JSON.stringify(Object.keys(value).sort()) ===
|
|
130
|
+
JSON.stringify(["attemptId", "durableCommitToken", "state"].sort()) &&
|
|
131
|
+
value.attemptId === attemptId &&
|
|
132
|
+
value.state === "active" &&
|
|
133
|
+
value.durableCommitToken === durableCommitToken);
|
|
134
|
+
}
|
|
135
|
+
function createOwnedDirectory(path) {
|
|
136
|
+
mkdirSync(path, { mode: 0o700 });
|
|
137
|
+
chmodSync(path, 0o700);
|
|
138
|
+
}
|
|
139
|
+
function canonicalFuturePath(path) {
|
|
140
|
+
const absolute = resolve(path);
|
|
141
|
+
return join(realpathSync.native(dirname(absolute)), basename(absolute));
|
|
142
|
+
}
|
|
143
|
+
function canonicalServerEndpoint(value) {
|
|
144
|
+
const endpoint = new URL(value);
|
|
145
|
+
if (!["nats:", "tls:"].includes(endpoint.protocol) || !endpoint.hostname ||
|
|
146
|
+
endpoint.username || endpoint.password || endpoint.search || endpoint.hash ||
|
|
147
|
+
(endpoint.pathname && endpoint.pathname !== "/"))
|
|
148
|
+
throw new Error(`restore listener endpoint is not canonicalizable: ${value}`);
|
|
149
|
+
return `${endpoint.protocol}//${endpoint.hostname.toLowerCase()}:${endpoint.port || "4222"}`;
|
|
150
|
+
}
|
|
151
|
+
function contains(parent, child) {
|
|
152
|
+
const path = relative(parent, child);
|
|
153
|
+
const separator = process.platform === "win32" ? "\\" : "/";
|
|
154
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${separator}`) && !isAbsolute(path));
|
|
155
|
+
}
|
|
156
|
+
function assertRestoreTargetPath(source, artifact, attempts, target) {
|
|
157
|
+
const canonicalTarget = canonicalFuturePath(target);
|
|
158
|
+
if (canonicalTarget !== source && (contains(source, canonicalTarget) || contains(canonicalTarget, source)))
|
|
159
|
+
throw new Error("restore target must not overlap the preserved source store");
|
|
160
|
+
if (contains(attempts, canonicalTarget) || contains(canonicalTarget, attempts))
|
|
161
|
+
throw new Error("restore target must not overlap the maintenance attempt directory");
|
|
162
|
+
if (contains(artifact, canonicalTarget) || contains(canonicalTarget, artifact))
|
|
163
|
+
throw new Error("restore target must not overlap the backup artifact");
|
|
164
|
+
}
|
|
165
|
+
function dataStateFingerprint(input) {
|
|
166
|
+
const state = validateBackupStreamState(input);
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
messages: state.messages,
|
|
169
|
+
bytes: state.bytes,
|
|
170
|
+
first_seq: state.first_seq,
|
|
171
|
+
last_seq: state.last_seq,
|
|
172
|
+
deleted: [...(state.deleted ?? [])].sort((a, b) => a - b),
|
|
173
|
+
num_deleted: state.num_deleted ?? 0,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function assertRestoredState(stream, expected, actual) {
|
|
177
|
+
if (dataStateFingerprint(expected) !== dataStateFingerprint(actual))
|
|
178
|
+
throw new Error(`${stream} restored message state does not match the backup manifest`);
|
|
179
|
+
}
|
|
180
|
+
async function validateManifest(manifest, flags, ready, currentAuthority) {
|
|
181
|
+
if (flags["restore-only"] !== undefined && flags["restore-only"] !== "registry")
|
|
182
|
+
throw new Error("--restore-only must be exactly registry");
|
|
183
|
+
if (flags["restore-only"] === "registry" && manifest.selection !== "registry" &&
|
|
184
|
+
!manifest.streams.some((entry) => entry.stream === spaceBackupInventory(manifest.space).registry[0]))
|
|
185
|
+
throw new Error("artifact does not contain the registry component");
|
|
186
|
+
if (manifest.space !== ready.space)
|
|
187
|
+
throw new Error(`backup belongs to space ${JSON.stringify(manifest.space)}, not ${JSON.stringify(ready.space)}`);
|
|
188
|
+
if (manifest.mode !== ready.mode)
|
|
189
|
+
throw new Error(`backup auth mode ${manifest.mode} does not match preserved source mode ${ready.mode}`);
|
|
190
|
+
if (flags.space && flags.space !== manifest.space)
|
|
191
|
+
throw new Error(`--space ${JSON.stringify(flags.space)} does not match backup space ${JSON.stringify(manifest.space)}`);
|
|
192
|
+
if (flags.open && manifest.mode !== "open")
|
|
193
|
+
throw new Error("--open conflicts with the backup auth mode");
|
|
194
|
+
if (flags["user-auth"] && manifest.mode !== "user")
|
|
195
|
+
throw new Error("--user-auth conflicts with the backup auth mode");
|
|
196
|
+
const inventory = spaceBackupInventory(manifest.space);
|
|
197
|
+
const selected = flags["restore-only"] === "registry" ? inventory.registry : inventory[manifest.selection];
|
|
198
|
+
const actual = manifest.streams.map((entry) => entry.stream);
|
|
199
|
+
if (new Set(actual).size !== actual.length || JSON.stringify([...actual].sort()) !== JSON.stringify([...inventory[manifest.selection]].sort()))
|
|
200
|
+
throw new Error("backup manifest stream inventory does not match its selection");
|
|
201
|
+
for (const stream of selected) {
|
|
202
|
+
const record = manifest.streams.find((entry) => entry.stream === stream);
|
|
203
|
+
if (!record)
|
|
204
|
+
throw new Error(`backup is missing selected stream ${stream}`);
|
|
205
|
+
validateCanonicalBackupStreamConfig(manifest.space, stream, record.config);
|
|
206
|
+
validateBackupStreamState(record.state);
|
|
207
|
+
}
|
|
208
|
+
if (manifest.selection === "full" && flags["restore-only"] !== "registry") {
|
|
209
|
+
if (!manifest.authority || JSON.stringify(manifest.authority) !== JSON.stringify(currentAuthority))
|
|
210
|
+
throw new Error("backup authority fingerprint does not match current trust state");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Smoke hook: `COTAL_SMOKE_FAIL_RESTORE_STREAM=<stream>` fires on any pass; `<pass>:<stream>`
|
|
214
|
+
* fires only on that pass (so the target pass is reachable after quarantine succeeded). */
|
|
215
|
+
function smokeFailRestoreStream(pass, stream) {
|
|
216
|
+
const spec = process.env.COTAL_SMOKE_FAIL_RESTORE_STREAM;
|
|
217
|
+
if (!spec)
|
|
218
|
+
return false;
|
|
219
|
+
const separator = spec.indexOf(":");
|
|
220
|
+
if (separator < 0)
|
|
221
|
+
return spec === stream;
|
|
222
|
+
return spec.slice(0, separator) === pass && spec.slice(separator + 1) === stream;
|
|
223
|
+
}
|
|
224
|
+
async function restoreStream(broker, space, snapshotDir, pass, stream) {
|
|
225
|
+
const initLogin = await broker.addLogin({
|
|
226
|
+
profile: "restore",
|
|
227
|
+
scope: { operation: "initiate", stream: stream.stream },
|
|
228
|
+
});
|
|
229
|
+
const init = await connectIsolatedBroker(broker, initLogin);
|
|
230
|
+
let session;
|
|
231
|
+
try {
|
|
232
|
+
session = await initiateStreamRestore(init, space, stream.stream, canonicalBackupStreamConfig(space, stream.stream), stream.state, RESTORE_TIMEOUT_MS);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await init.drain().catch(() => { });
|
|
236
|
+
}
|
|
237
|
+
const uploadLogin = await broker.addLogin({
|
|
238
|
+
profile: "restore",
|
|
239
|
+
scope: { operation: "upload", stream: stream.stream, deliverSubject: session.deliverSubject },
|
|
240
|
+
});
|
|
241
|
+
if (smokeFailRestoreStream(pass, stream.stream))
|
|
242
|
+
throw new Error(`smoke-injected exact-ID chunk handoff timeout for ${stream.stream} (${pass})`);
|
|
243
|
+
const upload = await connectIsolatedBroker(broker, uploadLogin);
|
|
244
|
+
const fd = openSync(join(snapshotDir, stream.snapshot), constants.O_RDONLY);
|
|
245
|
+
try {
|
|
246
|
+
const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
247
|
+
for (;;) {
|
|
248
|
+
const count = readSync(fd, buffer, 0, buffer.length, null);
|
|
249
|
+
if (count === 0)
|
|
250
|
+
break;
|
|
251
|
+
await uploadStreamRestoreChunk(upload, session, buffer.subarray(0, count), RESTORE_TIMEOUT_MS);
|
|
252
|
+
}
|
|
253
|
+
const restored = await finalizeStreamRestore(upload, session, RESTORE_TIMEOUT_MS);
|
|
254
|
+
validateCanonicalBackupStreamConfig(space, stream.stream, restored.config);
|
|
255
|
+
assertRestoredState(stream.stream, stream.state, restored.state);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
closeSync(fd);
|
|
259
|
+
await upload.drain().catch(() => { });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Re-snapshot one validated quarantine stream (`no_consumers` native form) into an attempt-owned
|
|
263
|
+
* pinned file. The real target is instantiated ONLY from these sanitized bytes, never from the
|
|
264
|
+
* archive-supplied snapshot. */
|
|
265
|
+
async function sanitizeStreamSnapshot(broker, space, stream, destination) {
|
|
266
|
+
const exact = await broker.addLogin((connId) => ({
|
|
267
|
+
profile: "backup",
|
|
268
|
+
scope: {
|
|
269
|
+
operation: "snapshot",
|
|
270
|
+
stream: stream.stream,
|
|
271
|
+
deliverSubject: `_INBOX_${connId}.sanitize.${randomUUID().replaceAll("-", "")}`,
|
|
272
|
+
},
|
|
273
|
+
}));
|
|
274
|
+
const deliverSubject = exact.scope.scope.deliverSubject;
|
|
275
|
+
const nc = await connectIsolatedBroker(broker, exact);
|
|
276
|
+
const fd = openSync(destination, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
277
|
+
try {
|
|
278
|
+
const metadata = await downloadStreamSnapshot(nc, stream.stream, {
|
|
279
|
+
deliverSubject,
|
|
280
|
+
timeoutMs: RESTORE_TIMEOUT_MS,
|
|
281
|
+
checkMessages: true,
|
|
282
|
+
onChunk: (chunk) => {
|
|
283
|
+
let offset = 0;
|
|
284
|
+
while (offset < chunk.byteLength) {
|
|
285
|
+
const written = writeSync(fd, chunk, offset, chunk.byteLength - offset);
|
|
286
|
+
if (written <= 0)
|
|
287
|
+
throw new Error("sanitized snapshot write made no progress");
|
|
288
|
+
offset += written;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
validateCanonicalBackupStreamConfig(space, stream.stream, metadata.config);
|
|
293
|
+
assertRestoredState(stream.stream, stream.state, metadata.state);
|
|
294
|
+
fsyncSync(fd);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
closeSync(fd);
|
|
298
|
+
await nc.drain().catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function validateRestoredStream(broker, space, record, expectedConsumers) {
|
|
302
|
+
const login = await broker.addLogin({
|
|
303
|
+
profile: "restore",
|
|
304
|
+
scope: { operation: "validate", stream: record.stream },
|
|
305
|
+
});
|
|
306
|
+
const nc = await connectIsolatedBroker(broker, login);
|
|
307
|
+
try {
|
|
308
|
+
const response = await nc.request(`$JS.API.STREAM.INFO.${record.stream}`, "{}", { timeout: RESTORE_TIMEOUT_MS });
|
|
309
|
+
const info = JSON.parse(response.string());
|
|
310
|
+
if (info.error)
|
|
311
|
+
throw new Error(info.error.description ?? `${record.stream} validation failed`);
|
|
312
|
+
if (!info.config || !info.state)
|
|
313
|
+
throw new Error(`${record.stream} validation returned an incomplete response`);
|
|
314
|
+
validateCanonicalBackupStreamConfig(space, record.stream, info.config);
|
|
315
|
+
assertRestoredState(record.stream, record.state, info.state);
|
|
316
|
+
if (info.state.consumer_count !== expectedConsumers)
|
|
317
|
+
throw new Error(`${record.stream} has ${info.state.consumer_count} consumers after restore; expected ${expectedConsumers}`);
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await nc.drain().catch(() => { });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function recreateCheckpoint(broker, space, checkpoint) {
|
|
324
|
+
const expected = consumerConfigFromCheckpoint(space, checkpoint);
|
|
325
|
+
const login = await broker.addLogin({
|
|
326
|
+
profile: "restore",
|
|
327
|
+
scope: { operation: "checkpoint", checkpoint },
|
|
328
|
+
});
|
|
329
|
+
const nc = await connectIsolatedBroker(broker, login);
|
|
330
|
+
try {
|
|
331
|
+
const info = await recreateConsumerCheckpoint(nc, space, checkpoint, RESTORE_TIMEOUT_MS);
|
|
332
|
+
if (info.config.opt_start_seq !== expected.opt_start_seq || info.config.deliver_policy !== expected.deliver_policy)
|
|
333
|
+
throw new Error(`${checkpoint.stream}/${checkpoint.name} did not preserve its conservative checkpoint floor`);
|
|
334
|
+
// A fresh durable is BORN with a native stream floor of effectiveStart - 1, where the
|
|
335
|
+
// effective start is its opt_start_seq or, for DeliverAll over a truncated WorkQueue (TASK),
|
|
336
|
+
// the stream's first_seq (verified against nats-server 2.14). That floor claims nothing
|
|
337
|
+
// delivered and replays everything still in the stream. Anything else — a delivered count, or
|
|
338
|
+
// a floor beyond the born value — would silently skip pre-cut entries.
|
|
339
|
+
const bornFloor = Math.max(expected.opt_start_seq ?? 1, checkpoint.streamState.first_seq || 1) - 1;
|
|
340
|
+
if (info.ack_floor.stream_seq !== bornFloor || info.ack_floor.consumer_seq !== 0)
|
|
341
|
+
throw new Error(`${checkpoint.stream}/${checkpoint.name} was recreated with ack floor ${info.ack_floor.stream_seq}/${info.ack_floor.consumer_seq}; expected the born floor ${bornFloor}/0`);
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
await nc.drain().catch(() => { });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function createOmittedInfrastructure(broker, space, registryOnly) {
|
|
348
|
+
const inventory = spaceBackupInventory(space);
|
|
349
|
+
const create = registryOnly ? inventory.full.filter((name) => !inventory.registry.includes(name)) : [];
|
|
350
|
+
const excluded = inventory.excluded.map((entry) => entry.name);
|
|
351
|
+
const login = await broker.addLogin({
|
|
352
|
+
profile: "infrastructure",
|
|
353
|
+
streams: [...create, ...excluded],
|
|
354
|
+
});
|
|
355
|
+
const nc = await connectIsolatedBroker(broker, login);
|
|
356
|
+
try {
|
|
357
|
+
const jsm = await jetstreamManager(nc);
|
|
358
|
+
for (const stream of create)
|
|
359
|
+
await jsm.streams.add(canonicalBackupStreamConfig(space, stream));
|
|
360
|
+
const kvm = new Kvm(nc);
|
|
361
|
+
await kvm.create(presenceBucket(space), { ttl: 6_000 });
|
|
362
|
+
await kvm.create(membershipBucket(space), { history: 1, max_bytes: MEMBERSHIP_MAX_BYTES });
|
|
363
|
+
await kvm.create(deliveryBucket(space), { ttl: LEASE_TTL_MS });
|
|
364
|
+
await kvm.create(managerBucket(space), { ttl: MANAGER_LEASE_TTL_MS });
|
|
365
|
+
for (const stream of [...create, ...excluded])
|
|
366
|
+
await jsm.streams.info(stream);
|
|
367
|
+
// The normal listener is exposed only over a complete space: assert the exact stream inventory
|
|
368
|
+
// (restored + created + excluded transient) before the coordinator may write commit intent.
|
|
369
|
+
const names = [];
|
|
370
|
+
for (let offset = 0;;) {
|
|
371
|
+
const message = await nc.request("$JS.API.STREAM.NAMES", JSON.stringify({ offset }), { timeout: RESTORE_TIMEOUT_MS });
|
|
372
|
+
const page = JSON.parse(message.string());
|
|
373
|
+
if (page.error)
|
|
374
|
+
throw new Error(`post-restore inventory listing failed: ${page.error.description ?? "JetStream error"}`);
|
|
375
|
+
if (!Number.isSafeInteger(page.total) || !Number.isSafeInteger(page.offset))
|
|
376
|
+
throw new Error("post-restore inventory listing returned an invalid page");
|
|
377
|
+
names.push(...(page.streams ?? []));
|
|
378
|
+
const next = page.offset + (page.streams?.length ?? 0);
|
|
379
|
+
if (next >= page.total)
|
|
380
|
+
break;
|
|
381
|
+
if (next <= offset)
|
|
382
|
+
throw new Error("post-restore inventory pagination made no progress");
|
|
383
|
+
offset = next;
|
|
384
|
+
}
|
|
385
|
+
validateSpaceBackupInventory(space, names);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await nc.drain().catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function restoreAndValidate(broker, space, staged, snapshotDir, pass, onlyRegistry, checkpoints, completeInfrastructure) {
|
|
392
|
+
const wanted = onlyRegistry ? spaceBackupInventory(space).registry : spaceBackupInventory(space)[staged.manifest.selection];
|
|
393
|
+
for (const name of wanted)
|
|
394
|
+
await restoreStream(broker, space, snapshotDir, pass, staged.manifest.streams.find((entry) => entry.stream === name));
|
|
395
|
+
for (const name of wanted)
|
|
396
|
+
await validateRestoredStream(broker, space, staged.manifest.streams.find((entry) => entry.stream === name), 0);
|
|
397
|
+
if (completeInfrastructure)
|
|
398
|
+
await createOmittedInfrastructure(broker, space, onlyRegistry);
|
|
399
|
+
if (!onlyRegistry)
|
|
400
|
+
for (const checkpoint of checkpoints)
|
|
401
|
+
await recreateCheckpoint(broker, space, checkpoint);
|
|
402
|
+
if (!onlyRegistry)
|
|
403
|
+
for (const name of wanted) {
|
|
404
|
+
const count = checkpoints.filter((checkpoint) => checkpoint.stream === name).length;
|
|
405
|
+
await validateRestoredStream(broker, space, staged.manifest.streams.find((entry) => entry.stream === name), count);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function removeOwnedDirectory(path, identity) {
|
|
409
|
+
try {
|
|
410
|
+
const stat = lstatSync(path, { bigint: true });
|
|
411
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === identity.dev && stat.ino === identity.ino)
|
|
412
|
+
rmSync(path, { recursive: true });
|
|
413
|
+
}
|
|
414
|
+
catch { /* absent or identity cannot be proven */ }
|
|
415
|
+
}
|
|
416
|
+
export async function prepareRestore(root, flags) {
|
|
417
|
+
const attemptId = `restore-${randomUUID()}`;
|
|
418
|
+
const serverNonce = randomUUID().replaceAll("-", "");
|
|
419
|
+
const serverName = `${attemptId}-${serverNonce}`;
|
|
420
|
+
const coordinator = {
|
|
421
|
+
pid: process.pid, host: hostname(), startedAt: new Date().toISOString(), id: `${attemptId}-coordinator`,
|
|
422
|
+
};
|
|
423
|
+
const deadline = new Date(Date.now() + RESTORE_TIMEOUT_MS);
|
|
424
|
+
let lock = acquireMaintenanceLock(root);
|
|
425
|
+
let staged;
|
|
426
|
+
let transitioned = false;
|
|
427
|
+
let targetBound = false;
|
|
428
|
+
let targetIdentity;
|
|
429
|
+
let quarantine;
|
|
430
|
+
let sanitized;
|
|
431
|
+
let broker;
|
|
432
|
+
let quarantineBroker;
|
|
433
|
+
const recordResources = (input) => {
|
|
434
|
+
const resourceLock = acquireMaintenanceLock(root);
|
|
435
|
+
try {
|
|
436
|
+
recordRestoreAttemptResources(resourceLock, input);
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
releaseMaintenanceLock(resourceLock);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
try {
|
|
443
|
+
let journal = readMaintenanceJournal(root);
|
|
444
|
+
if (journal?.state === "restore-ready") {
|
|
445
|
+
// Never touch a live attempt: recover by rollback only once the recorded claim is provably
|
|
446
|
+
// stale (deadline elapsed, coordinator/watchdogs/brokers all dead).
|
|
447
|
+
const assessment = assessRestoreClaim(journal);
|
|
448
|
+
if (assessment === "live")
|
|
449
|
+
throw new Error(`restore attempt ${journal.restore.attemptId} is in progress (claim live until ${journal.claim.deadline}); retry after it completes or becomes provably stale`);
|
|
450
|
+
if (assessment === "ambiguous")
|
|
451
|
+
throw new Error(`restore attempt ${journal.restore.attemptId} owners cannot be proven dead; inspect the recorded coordinator, watchdog, and broker processes before recovery`);
|
|
452
|
+
journal = rollbackRestore(lock);
|
|
453
|
+
}
|
|
454
|
+
if (!journal || journal.state !== "ready")
|
|
455
|
+
throw new Error(`restore requires stable ready maintenance state, found ${journal?.state ?? "none"}`);
|
|
456
|
+
const auth = journal.mode === "open" ? undefined : loadSpaceAuth(authDir(root));
|
|
457
|
+
if (journal.mode !== "open")
|
|
458
|
+
validateSpaceAuth(auth, journal.space);
|
|
459
|
+
// Compute user-provider continuity before staging too. The later manifest comparison reuses this
|
|
460
|
+
// value, so malformed trust cannot create an attempt target or move the preserved source first.
|
|
461
|
+
const currentAuthority = await authorityFingerprint(root, journal.space, journal.mode);
|
|
462
|
+
const stageParent = ensurePrivateAttemptsDir(root).path;
|
|
463
|
+
const artifactPath = realpathSync.native(resolve(flags.restore));
|
|
464
|
+
if (contains(journal.source.path, artifactPath) || contains(artifactPath, journal.source.path))
|
|
465
|
+
throw new Error("restore artifact must not overlap the preserved source store");
|
|
466
|
+
if (contains(stageParent, artifactPath) || contains(artifactPath, stageParent))
|
|
467
|
+
throw new Error("restore artifact must not overlap the maintenance attempt directory");
|
|
468
|
+
const targetPath = resolve(flags["store-dir"] ?? join(root, ".cotal", "nats"));
|
|
469
|
+
assertRestoreTargetPath(journal.source.path, artifactPath, stageParent, targetPath);
|
|
470
|
+
// The journal is ready with no live claim, and the artifact/target are proven disjoint from
|
|
471
|
+
// the attempts directory: any attempts-dir content is dead-attempt residue.
|
|
472
|
+
sweepAttemptResidue(root);
|
|
473
|
+
const artifact = stageArtifact(artifactPath, stageParent);
|
|
474
|
+
staged = artifact;
|
|
475
|
+
await validateManifest(artifact.manifest, flags, journal, currentAuthority);
|
|
476
|
+
const checkpoints = readStagedCheckpoints(artifact);
|
|
477
|
+
const resume = readMaintenanceResumeDocument(root, journal.resume);
|
|
478
|
+
const inventoryAgents = resume.inventory.agents ?? [];
|
|
479
|
+
const inventoryRuntimes = [...new Set(inventoryAgents
|
|
480
|
+
.map((agent) => agent.launch?.runtime)
|
|
481
|
+
.filter((value) => typeof value === "string"))];
|
|
482
|
+
if (inventoryRuntimes.length > 1)
|
|
483
|
+
throw new Error(`resume inventory requires multiple runtimes: ${inventoryRuntimes.join(", ")}`);
|
|
484
|
+
// The partial selection is a property of the artifact, not only of the flag: a registry-only
|
|
485
|
+
// artifact restored without --restore-only registry must still create omitted infrastructure.
|
|
486
|
+
const onlyRegistry = flags["restore-only"] === "registry" || artifact.manifest.selection === "registry";
|
|
487
|
+
// Retained agents relaunch under their preserved runtime; a contradicting override must fail
|
|
488
|
+
// in preflight, before any mutation or listener exposure.
|
|
489
|
+
if (!onlyRegistry && flags.runtime && inventoryRuntimes[0] && flags.runtime !== inventoryRuntimes[0])
|
|
490
|
+
throw new Error(`--runtime ${flags.runtime} contradicts the preserved agent runtime ${inventoryRuntimes[0]}; omit it or restore registry-only`);
|
|
491
|
+
const effectiveRuntime = flags.runtime ?? inventoryRuntimes[0] ?? "pty";
|
|
492
|
+
const wanted = onlyRegistry ? spaceBackupInventory(journal.space).registry : spaceBackupInventory(journal.space)[artifact.manifest.selection];
|
|
493
|
+
for (const checkpoint of onlyRegistry ? [] : checkpoints) {
|
|
494
|
+
if (!wanted.includes(checkpoint.stream))
|
|
495
|
+
throw new Error(`checkpoint ${checkpoint.stream}/${checkpoint.name} is outside the restored selection`);
|
|
496
|
+
const streamRecord = artifact.manifest.streams.find((entry) => entry.stream === checkpoint.stream);
|
|
497
|
+
if (!streamRecord)
|
|
498
|
+
throw new Error(`checkpoint ${checkpoint.stream}/${checkpoint.name} has no snapshot stream`);
|
|
499
|
+
const snapshotState = validateBackupStreamState(streamRecord.state);
|
|
500
|
+
if (JSON.stringify(checkpoint.streamState) !== JSON.stringify({
|
|
501
|
+
messages: snapshotState.messages,
|
|
502
|
+
first_seq: snapshotState.first_seq,
|
|
503
|
+
last_seq: snapshotState.last_seq,
|
|
504
|
+
}))
|
|
505
|
+
throw new Error(`checkpoint ${checkpoint.stream}/${checkpoint.name} does not match its snapshot stream state`);
|
|
506
|
+
consumerConfigFromCheckpoint(journal.space, checkpoint);
|
|
507
|
+
}
|
|
508
|
+
const authorityBeforeMutation = await authorityFingerprint(root, journal.space, journal.mode);
|
|
509
|
+
if (JSON.stringify(authorityBeforeMutation) !== JSON.stringify(currentAuthority))
|
|
510
|
+
throw new Error("current trust state changed during restore validation");
|
|
511
|
+
await validateManifest(artifact.manifest, flags, journal, authorityBeforeMutation);
|
|
512
|
+
const sourceExists = (() => {
|
|
513
|
+
try {
|
|
514
|
+
lstatSync(journal.source.path);
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (error.code === "ENOENT")
|
|
519
|
+
return false;
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
})();
|
|
523
|
+
const stagedStat = lstatSync(artifact.directory, { bigint: true });
|
|
524
|
+
const claim = {
|
|
525
|
+
deadline: deadline.toISOString(),
|
|
526
|
+
coordinator,
|
|
527
|
+
ownedPaths: [{
|
|
528
|
+
label: "staging", path: artifact.directory,
|
|
529
|
+
dev: stagedStat.dev.toString(), ino: stagedStat.ino.toString(),
|
|
530
|
+
}],
|
|
531
|
+
};
|
|
532
|
+
if (!sourceExists) {
|
|
533
|
+
if (!flags["accept-missing-source"])
|
|
534
|
+
throw new Error("preserved source is missing; pass --accept-missing-source only after verifying disaster recovery intent");
|
|
535
|
+
prepareMissingSourceRestore(lock, { attemptId, targetPath, claim });
|
|
536
|
+
}
|
|
537
|
+
else if (targetPath === journal.source.path) {
|
|
538
|
+
const fallbackPath = join(dirname(targetPath), `.cotal-restore-fallback-${attemptId}`);
|
|
539
|
+
prepareSamePathRestore(lock, { attemptId, targetPath, fallbackPath, claim });
|
|
540
|
+
moveSamePathRestoreSource(lock);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
prepareAlternateRestore(lock, { attemptId, targetPath, claim });
|
|
544
|
+
}
|
|
545
|
+
transitioned = true;
|
|
546
|
+
createOwnedDirectory(targetPath);
|
|
547
|
+
const targetStat = lstatSync(targetPath, { bigint: true });
|
|
548
|
+
targetIdentity = { dev: targetStat.dev, ino: targetStat.ino };
|
|
549
|
+
bindRestoreTarget(lock);
|
|
550
|
+
targetBound = true;
|
|
551
|
+
releaseMaintenanceLock(lock);
|
|
552
|
+
lock = undefined;
|
|
553
|
+
// The lock is held across broker spawn → owner record so no live broker can exist outside the
|
|
554
|
+
// journaled claim, even across a crash in between; stale recovery then never races an orphan.
|
|
555
|
+
lock = acquireMaintenanceLock(root);
|
|
556
|
+
const quarantinePath = join(stageParent, `${attemptId}-quarantine`);
|
|
557
|
+
createOwnedDirectory(quarantinePath);
|
|
558
|
+
const quarantineStat = lstatSync(quarantinePath, { bigint: true });
|
|
559
|
+
quarantine = { path: quarantinePath, identity: { dev: quarantineStat.dev, ino: quarantineStat.ino } };
|
|
560
|
+
recordRestoreAttemptResources(lock, { ownedPaths: [{
|
|
561
|
+
label: "quarantine", path: quarantinePath,
|
|
562
|
+
dev: quarantineStat.dev.toString(), ino: quarantineStat.ino.toString(),
|
|
563
|
+
}] });
|
|
564
|
+
quarantineBroker = await startIsolatedBroker({
|
|
565
|
+
root,
|
|
566
|
+
storeDir: quarantinePath,
|
|
567
|
+
space: journal.space,
|
|
568
|
+
mode: journal.mode,
|
|
569
|
+
auth,
|
|
570
|
+
deadline,
|
|
571
|
+
label: "quarantine",
|
|
572
|
+
initialScope: { profile: "restore", scope: { operation: "initiate", stream: wanted[0] } },
|
|
573
|
+
attemptId,
|
|
574
|
+
});
|
|
575
|
+
recordRestoreAttemptResources(lock, {
|
|
576
|
+
owners: [quarantineBroker.brokerOwner, quarantineBroker.watchdogOwner],
|
|
577
|
+
ownedPaths: quarantineBroker.runFiles.map((file) => ({ label: "config", ...file })),
|
|
578
|
+
});
|
|
579
|
+
releaseMaintenanceLock(lock);
|
|
580
|
+
lock = undefined;
|
|
581
|
+
try {
|
|
582
|
+
await restoreAndValidate(quarantineBroker, journal.space, artifact, artifact.directory, "quarantine", onlyRegistry, [], false);
|
|
583
|
+
// Re-derive every snapshot from the VALIDATED quarantine state. Only these sanitized bytes
|
|
584
|
+
// may instantiate the real target; the archive-supplied snapshots never touch it.
|
|
585
|
+
// Ownership precedes existence: the slot is journaled pending, created, then inode-upgraded.
|
|
586
|
+
const sanitizedPath = join(stageParent, `${attemptId}-sanitized`);
|
|
587
|
+
const sanitizedLock = acquireMaintenanceLock(root);
|
|
588
|
+
try {
|
|
589
|
+
recordRestoreAttemptResources(sanitizedLock, { ownedPaths: [{ label: "sanitized", path: sanitizedPath }] });
|
|
590
|
+
createOwnedDirectory(sanitizedPath);
|
|
591
|
+
const sanitizedStat = lstatSync(sanitizedPath, { bigint: true });
|
|
592
|
+
sanitized = { path: sanitizedPath, identity: { dev: sanitizedStat.dev, ino: sanitizedStat.ino } };
|
|
593
|
+
recordRestoreAttemptResources(sanitizedLock, { ownedPaths: [{
|
|
594
|
+
label: "sanitized", path: sanitizedPath,
|
|
595
|
+
dev: sanitizedStat.dev.toString(), ino: sanitizedStat.ino.toString(),
|
|
596
|
+
}] });
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
releaseMaintenanceLock(sanitizedLock);
|
|
600
|
+
}
|
|
601
|
+
for (const name of wanted) {
|
|
602
|
+
const record = artifact.manifest.streams.find((entry) => entry.stream === name);
|
|
603
|
+
await sanitizeStreamSnapshot(quarantineBroker, journal.space, record, join(sanitizedPath, record.snapshot));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
await quarantineBroker.stop();
|
|
608
|
+
quarantineBroker = undefined;
|
|
609
|
+
}
|
|
610
|
+
removeOwnedDirectory(quarantine.path, quarantine.identity);
|
|
611
|
+
quarantine = undefined;
|
|
612
|
+
lock = acquireMaintenanceLock(root);
|
|
613
|
+
broker = await startIsolatedBroker({
|
|
614
|
+
root,
|
|
615
|
+
storeDir: targetPath,
|
|
616
|
+
space: journal.space,
|
|
617
|
+
mode: journal.mode,
|
|
618
|
+
auth,
|
|
619
|
+
deadline,
|
|
620
|
+
label: "restore",
|
|
621
|
+
initialScope: { profile: "restore", scope: { operation: "initiate", stream: wanted[0] } },
|
|
622
|
+
attemptId,
|
|
623
|
+
});
|
|
624
|
+
recordRestoreAttemptResources(lock, {
|
|
625
|
+
owners: [broker.brokerOwner, broker.watchdogOwner],
|
|
626
|
+
ownedPaths: broker.runFiles.map((file) => ({ label: "config", ...file })),
|
|
627
|
+
});
|
|
628
|
+
releaseMaintenanceLock(lock);
|
|
629
|
+
lock = undefined;
|
|
630
|
+
await restoreAndValidate(broker, journal.space, artifact, sanitized.path, "target", onlyRegistry, checkpoints, true);
|
|
631
|
+
await broker.stop();
|
|
632
|
+
broker = undefined;
|
|
633
|
+
lock = acquireMaintenanceLock(root);
|
|
634
|
+
const resumeLaunch = (resume.launch ?? {});
|
|
635
|
+
const effectiveServer = canonicalServerEndpoint(flags.server ?? (typeof resumeLaunch.server === "string" ? resumeLaunch.server : "nats://127.0.0.1:4222"));
|
|
636
|
+
const effectiveHost = flags.host ?? (() => {
|
|
637
|
+
try {
|
|
638
|
+
return new URL(effectiveServer).hostname;
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return "127.0.0.1";
|
|
642
|
+
}
|
|
643
|
+
})();
|
|
644
|
+
writeRestoreCommitIntent(lock, {
|
|
645
|
+
attemptId,
|
|
646
|
+
targetPath,
|
|
647
|
+
space: journal.space,
|
|
648
|
+
mode: journal.mode,
|
|
649
|
+
server: effectiveServer,
|
|
650
|
+
host: effectiveHost,
|
|
651
|
+
runtime: effectiveRuntime,
|
|
652
|
+
detached: Boolean(flags.detach),
|
|
653
|
+
restoreSource: resolve(flags.restore),
|
|
654
|
+
restoreOnly: flags["restore-only"] ?? artifact.manifest.selection,
|
|
655
|
+
acceptMissingSource: Boolean(flags["accept-missing-source"]),
|
|
656
|
+
serverName,
|
|
657
|
+
serverNonce,
|
|
658
|
+
});
|
|
659
|
+
releaseMaintenanceLock(lock);
|
|
660
|
+
lock = undefined;
|
|
661
|
+
return {
|
|
662
|
+
root,
|
|
663
|
+
attemptId,
|
|
664
|
+
targetPath,
|
|
665
|
+
space: journal.space,
|
|
666
|
+
mode: journal.mode,
|
|
667
|
+
server: effectiveServer,
|
|
668
|
+
host: effectiveHost,
|
|
669
|
+
runtime: effectiveRuntime,
|
|
670
|
+
detached: Boolean(flags.detach),
|
|
671
|
+
selection: onlyRegistry ? "registry" : artifact.manifest.selection,
|
|
672
|
+
inventory: resume.inventory,
|
|
673
|
+
serverName,
|
|
674
|
+
serverNonce,
|
|
675
|
+
reentry: false,
|
|
676
|
+
journalState: "commit-intent",
|
|
677
|
+
cleanupStage: () => {
|
|
678
|
+
artifact.cleanup();
|
|
679
|
+
if (sanitized)
|
|
680
|
+
removeOwnedDirectory(sanitized.path, sanitized.identity);
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
const shutdownFailures = [];
|
|
686
|
+
if (broker)
|
|
687
|
+
try {
|
|
688
|
+
await broker.stop();
|
|
689
|
+
broker = undefined;
|
|
690
|
+
}
|
|
691
|
+
catch (cause) {
|
|
692
|
+
shutdownFailures.push(cause instanceof Error ? cause : new Error(String(cause)));
|
|
693
|
+
}
|
|
694
|
+
if (quarantineBroker)
|
|
695
|
+
try {
|
|
696
|
+
await quarantineBroker.stop();
|
|
697
|
+
quarantineBroker = undefined;
|
|
698
|
+
}
|
|
699
|
+
catch (cause) {
|
|
700
|
+
shutdownFailures.push(cause instanceof Error ? cause : new Error(String(cause)));
|
|
701
|
+
}
|
|
702
|
+
if (shutdownFailures.length)
|
|
703
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; isolated broker exit could not be proven, so stores and staging were preserved: ${shutdownFailures.map((failure) => failure.message).join("; ")}`);
|
|
704
|
+
if (quarantine)
|
|
705
|
+
removeOwnedDirectory(quarantine.path, quarantine.identity);
|
|
706
|
+
if (sanitized)
|
|
707
|
+
removeOwnedDirectory(sanitized.path, sanitized.identity);
|
|
708
|
+
if (!lock)
|
|
709
|
+
lock = acquireMaintenanceLock(root);
|
|
710
|
+
if (transitioned && !targetBound && targetIdentity) {
|
|
711
|
+
removeOwnedDirectory(resolve(flags["store-dir"] ?? join(root, ".cotal", "nats")), targetIdentity);
|
|
712
|
+
targetIdentity = undefined;
|
|
713
|
+
}
|
|
714
|
+
let rollbackFailure;
|
|
715
|
+
if (transitioned)
|
|
716
|
+
try {
|
|
717
|
+
rollbackRestore(lock, { asCoordinator: coordinator });
|
|
718
|
+
}
|
|
719
|
+
catch (cause) {
|
|
720
|
+
rollbackFailure = cause instanceof Error ? cause : new Error(String(cause));
|
|
721
|
+
}
|
|
722
|
+
if (!rollbackFailure && targetIdentity)
|
|
723
|
+
removeOwnedDirectory(resolve(flags["store-dir"] ?? join(root, ".cotal", "nats")), targetIdentity);
|
|
724
|
+
staged?.cleanup();
|
|
725
|
+
if (rollbackFailure)
|
|
726
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; pre-commit rollback also failed: ${rollbackFailure.message}`);
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
if (lock)
|
|
731
|
+
releaseMaintenanceLock(lock);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
export function markPreparedRestoreActive(prepared, managerResult) {
|
|
735
|
+
if (!isManagerFinalizeEvidence(managerResult, prepared.attemptId, prepared.managerCommit?.durableCommitToken ?? ""))
|
|
736
|
+
throw new Error(`restore attempt ${prepared.attemptId} has invalid manager finalize evidence`);
|
|
737
|
+
const lock = acquireMaintenanceLock(prepared.root);
|
|
738
|
+
try {
|
|
739
|
+
const journal = readMaintenanceJournal(prepared.root);
|
|
740
|
+
if (!journal || !["manager-committed", "active", "degraded"].includes(journal.state))
|
|
741
|
+
throw new Error(`restore activation journal does not match attempt ${prepared.attemptId}`);
|
|
742
|
+
const restoreJournal = journal;
|
|
743
|
+
if (restoreJournal.restore.attemptId !== prepared.attemptId)
|
|
744
|
+
throw new Error(`restore activation journal does not match attempt ${prepared.attemptId}`);
|
|
745
|
+
if (restoreJournal.state === "active") {
|
|
746
|
+
if (!isManagerCommittedRestore(restoreJournal))
|
|
747
|
+
throw new Error(`restore attempt ${prepared.attemptId} is active without durable manager commit evidence`);
|
|
748
|
+
markRestoreActive(lock, prepared.listenerProof, managerResult);
|
|
749
|
+
prepared.journalState = "active";
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (!prepared.listenerProof)
|
|
753
|
+
throw new Error(`restore attempt ${prepared.attemptId} has no bound listener proof at activation`);
|
|
754
|
+
if (restoreJournal.state === "manager-committed")
|
|
755
|
+
markRestoreActive(lock, prepared.listenerProof, managerResult);
|
|
756
|
+
else if (restoreJournal.managerCommit)
|
|
757
|
+
repairRestoreDegradedToActive(lock, prepared.listenerProof, managerResult);
|
|
758
|
+
else
|
|
759
|
+
throw new Error(`restore activation journal does not contain durable manager commit evidence for ${prepared.attemptId}`);
|
|
760
|
+
prepared.journalState = "active";
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
releaseMaintenanceLock(lock);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
export function recordPreparedRestoreManagerCommit(prepared, evidence) {
|
|
767
|
+
if (!isManagerCommitResult(evidence, prepared.attemptId))
|
|
768
|
+
throw new Error(`restore attempt ${prepared.attemptId} has invalid manager commit evidence`);
|
|
769
|
+
if (!prepared.listenerProof)
|
|
770
|
+
throw new Error(`restore attempt ${prepared.attemptId} has no bound listener proof at manager commit`);
|
|
771
|
+
const lock = acquireMaintenanceLock(prepared.root);
|
|
772
|
+
try {
|
|
773
|
+
recordRestoreManagerCommit(lock, prepared.listenerProof, evidence);
|
|
774
|
+
prepared.managerCommit = evidence;
|
|
775
|
+
prepared.journalState = "manager-committed";
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
releaseMaintenanceLock(lock);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
export function markPreparedRestoreDegraded(root, attemptId, reason) {
|
|
782
|
+
const lock = acquireMaintenanceLock(root);
|
|
783
|
+
try {
|
|
784
|
+
const journal = readMaintenanceJournal(root);
|
|
785
|
+
if (!journal || (journal.state !== "commit-intent" && journal.state !== "active") || journal.restore.attemptId !== attemptId)
|
|
786
|
+
return;
|
|
787
|
+
markRestoreDegraded(lock, reason, [{
|
|
788
|
+
action: "repair",
|
|
789
|
+
description: "Preserve the restored target and retained source; recover forward before cleanup.",
|
|
790
|
+
paths: [journal.restore.target.path, journal.restore.previousSource?.identity.path].filter((path) => Boolean(path)),
|
|
791
|
+
}]);
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
releaseMaintenanceLock(lock);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=restore.js.map
|