@uns-kit/core 3.0.45 → 3.0.47
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/MIGRATIONS.md +31 -0
- package/dist/runtime/catalog-publication-client.d.ts +21 -0
- package/dist/runtime/catalog-publication-client.d.ts.map +1 -0
- package/dist/runtime/catalog-publication-client.js +92 -0
- package/dist/runtime/catalog-publication-client.js.map +1 -0
- package/dist/runtime/catalog-publication.d.ts +97 -0
- package/dist/runtime/catalog-publication.d.ts.map +1 -0
- package/dist/runtime/catalog-publication.js +185 -0
- package/dist/runtime/catalog-publication.js.map +1 -0
- package/dist/runtime/retained-store-copy.d.mts +3 -0
- package/dist/runtime/retained-store-copy.mjs +133 -0
- package/dist/runtime/retained-store-guide.md +44 -2
- package/dist/runtime/retained-store-job.d.mts +1 -0
- package/dist/runtime/retained-store-job.mjs +28 -0
- package/dist/runtime/retained-store-storage.d.mts +7 -0
- package/dist/runtime/retained-store-storage.mjs +66 -21
- package/dist/runtime/retained-store.d.ts +12 -3
- package/dist/runtime/retained-store.d.ts.map +1 -1
- package/dist/runtime/retained-store.js +74 -19
- package/dist/runtime/retained-store.js.map +1 -1
- package/dist/runtime/service-access-guide.md +130 -0
- package/dist/runtime/service-access.d.ts +23 -0
- package/dist/runtime/service-access.d.ts.map +1 -0
- package/dist/runtime/service-access.js +62 -0
- package/dist/runtime/service-access.js.map +1 -0
- package/dist/runtime/service-access.test.d.ts +2 -0
- package/dist/runtime/service-access.test.d.ts.map +1 -0
- package/dist/runtime/service-access.test.js +57 -0
- package/dist/runtime/service-access.test.js.map +1 -0
- package/package.json +14 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Retained stores: opt-in same-host adoption
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Retained-store adoption starts in UNS Kit 3.0.45. The 3.0.46 extension adds
|
|
4
|
+
explicit copying between local mounts and verification of recovered stores.
|
|
5
|
+
Managed activation requires coordinated Controller and Runtime releases. Existing applications do not change behavior.
|
|
5
6
|
This is a filesystem capability, separate from the small JSON
|
|
6
7
|
handover-state contract and MQTT output persistence. It does not change the
|
|
7
8
|
default handover protocol, enable disk queues, or transfer data between hosts.
|
|
@@ -120,3 +121,44 @@ cannot be used as a complete queue restore. Restore retains store/owner/generati
|
|
|
120
121
|
identity at the same logical root on another offline volume, and leaves an applying
|
|
121
122
|
fence until complete. Known private partial staging files may be re-created only
|
|
122
123
|
from the verified snapshot; differing published work is never overwritten.
|
|
124
|
+
|
|
125
|
+
## Cross-mount transfer and recovery verification in 3.0.46
|
|
126
|
+
|
|
127
|
+
Same-filesystem rename remains the default.
|
|
128
|
+
For initial relocation to a separately mounted Runtime volume, the host may
|
|
129
|
+
explicitly select `transfer: "copy-verified"`. Stop the source cleanly **before**
|
|
130
|
+
reviewing this transport. Review pins content hashes, aggregate byte/file counts,
|
|
131
|
+
resource identities, both installed artifacts, configuration and supervisor epoch.
|
|
132
|
+
There is no automatic `EXDEV` fallback or caller-selected filesystem path.
|
|
133
|
+
|
|
134
|
+
The host uses `prepareRetainedStoreMoveAsync`, `applyRetainedStoreMoveAsync` and
|
|
135
|
+
`prepareRetainedStoreReturnAsync`. Bounded worker jobs perform opaque hashing and
|
|
136
|
+
copying while the host event loop can serve health and recovery reads. Persist the
|
|
137
|
+
complete plan before mutation and hold the existing maintenance/lifecycle fences
|
|
138
|
+
through the durable outcome. Jobs have a 128MiB old-generation heap and a 300s
|
|
139
|
+
bound; uncertainty always requires reconciliation of the same operation.
|
|
140
|
+
|
|
141
|
+
Copy stages and fsyncs each declared resource on its receiving mount, verifies
|
|
142
|
+
its exact contents and atomically publishes it. Source files remain unchanged and
|
|
143
|
+
fenced; allow space for both copies. Known incomplete scratch can be resumed from
|
|
144
|
+
the unchanged reviewed source. Unknown entries, links, changed source bytes and
|
|
145
|
+
differing published files are preserved and refused. The existing limits remain
|
|
146
|
+
32 stores, 100,000 entries, 64GiB aggregate files and 32GiB per file.
|
|
147
|
+
|
|
148
|
+
Return copies the **current** stopped store back. If current work differs from
|
|
149
|
+
the original, the original is first preserved as
|
|
150
|
+
`.retained-original-<store UUID>-<resource ID>` beside its legacy path. The retired
|
|
151
|
+
stable copy remains inert and is included by full backup; it cannot acquire a
|
|
152
|
+
runtime lease. No cleanup, resource merge, conversion or cross-host transfer is
|
|
153
|
+
implied. Runtime disk capacity and the application's destination acknowledgement
|
|
154
|
+
semantics remain independent operational requirements.
|
|
155
|
+
|
|
156
|
+
Offline restore now retains a verified local snapshot manifest. The data-only
|
|
157
|
+
`verify-restored` / `verify-root` actions rehash exact declared payloads under an
|
|
158
|
+
exclusive root lease before Runtime activation. A completed restore retry never
|
|
159
|
+
replaces newer application data. Full RTT stage/activation/completion binds the
|
|
160
|
+
verified root, signed backup digest and local manifest digest into its protected
|
|
161
|
+
receipts. Missing, excluded, unfinished or altered storage cannot enable bound
|
|
162
|
+
owners. After application work advances, do not activate an old snapshot: preserve
|
|
163
|
+
current storage and use a fresh reviewed owner transition. Historic operation
|
|
164
|
+
plans pin original physical files and are not rebased after a full restore.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function retainedStoreJob(job: unknown, onProgress?: (id: string) => void): Promise<unknown>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { inspectRetainedCopy, applyRetainedCopy } from "./retained-store-copy.mjs";
|
|
3
|
+
if (!isMainThread) {
|
|
4
|
+
const job = workerData;
|
|
5
|
+
if (job.action === "inspect") parentPort.postMessage({ done: true, result: inspectRetainedCopy(job.path, job.kind, job.limits) });
|
|
6
|
+
else if (job.action === "apply") {
|
|
7
|
+
applyRetainedCopy(job.plan, job.direction, job.limits, id => parentPort.postMessage({ progress: id }));
|
|
8
|
+
parentPort.postMessage({ done: true });
|
|
9
|
+
} else throw Error("Unknown retained storage job");
|
|
10
|
+
}
|
|
11
|
+
export function retainedStoreJob(job, onProgress) {
|
|
12
|
+
if (!isMainThread) throw Error("Nested retained storage job");
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const worker = new Worker(new URL(import.meta.url), { workerData: job, execArgv: [], env: { NODE_NO_WARNINGS: "1" }, resourceLimits: { maxOldGenerationSizeMb: 128 } });
|
|
15
|
+
let done = false;
|
|
16
|
+
const timer = setTimeout(() => finish(Error("Retained storage job timed out; reconcile the same operation")), 300000);
|
|
17
|
+
const finish = (error, result) => {
|
|
18
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
19
|
+
void worker.terminate().finally(() => error ? reject(error) : resolve(result));
|
|
20
|
+
};
|
|
21
|
+
worker.on("message", message => {
|
|
22
|
+
if (message.progress) { try { onProgress?.(message.progress); } catch (error) { finish(error); } }
|
|
23
|
+
if (message.done) finish(undefined, message.result);
|
|
24
|
+
});
|
|
25
|
+
worker.once("error", error => finish(error));
|
|
26
|
+
worker.once("exit", () => { if (!done) finish(Error("Retained storage job exited without confirmation; reconcile the same operation")); });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -15,3 +15,10 @@ export function restoreRetained(
|
|
|
15
15
|
afterPublish?: (id: string) => void,
|
|
16
16
|
): { state: string; stores: number };
|
|
17
17
|
export function available(root: string): void;
|
|
18
|
+
export function owned(path: string, directory?: boolean): import("node:fs").Stats;
|
|
19
|
+
export function sha(path: string): string;
|
|
20
|
+
export function sync(path: string): void;
|
|
21
|
+
export function relative(path: string): boolean;
|
|
22
|
+
export function checkLimit(limits: unknown): void;
|
|
23
|
+
export function verifyRestoredRetained(source:string,root:string,backupDigest:string,limits:unknown):{state:string;stores:number;backupDigest:string;manifestDigest:string;root:string};
|
|
24
|
+
export function verifyRetainedRecoveryRoot(root:string,backupDigest:string,manifestDigest:string,limits:unknown):{state:string;stores:number;backupDigest:string;manifestDigest:string;root:string};
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
|
|
6
6
|
export const RESTORE_FILE = "retained-store-restore.json";
|
|
7
|
+
export const RESTORE_MANIFEST = "retained-store-restore-manifest.json";
|
|
7
8
|
export const DECLARATION = "store-resources.json";
|
|
8
9
|
export const OPERATION = "store-operation.json";
|
|
9
10
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -21,7 +22,7 @@ export function safePath(p) {
|
|
|
21
22
|
if (!path.isAbsolute(p) || path.normalize(p) !== p) throw Error("Unsafe retained path");
|
|
22
23
|
for (let a = p; a !== path.dirname(a); a = path.dirname(a)) if (entry(a)?.isSymbolicLink()) throw Error("Linked retained path");
|
|
23
24
|
}
|
|
24
|
-
function owned(p, directory = false) {
|
|
25
|
+
export function owned(p, directory = false) {
|
|
25
26
|
safePath(p);
|
|
26
27
|
const s = entry(p);
|
|
27
28
|
if (!s || s.uid !== process.getuid?.() || s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1))
|
|
@@ -38,7 +39,7 @@ export function readJSON(p, limit = 131072) {
|
|
|
38
39
|
if (s.size > limit) throw Error("Retained metadata limit");
|
|
39
40
|
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
40
41
|
}
|
|
41
|
-
function sync(p) {
|
|
42
|
+
export function sync(p) {
|
|
42
43
|
const fd = fs.openSync(p, "r");
|
|
43
44
|
try {
|
|
44
45
|
fs.fsyncSync(fd);
|
|
@@ -147,7 +148,7 @@ export function runtimeLease(binding) {
|
|
|
147
148
|
throw e;
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
|
-
function sha(file) {
|
|
151
|
+
export function sha(file) {
|
|
151
152
|
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
152
153
|
const h = createHash("sha256"),
|
|
153
154
|
buffer = Buffer.alloc(65536);
|
|
@@ -159,7 +160,7 @@ function sha(file) {
|
|
|
159
160
|
fs.closeSync(fd);
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
|
-
function relative(p) {
|
|
163
|
+
export function relative(p) {
|
|
163
164
|
return (
|
|
164
165
|
typeof p === "string" &&
|
|
165
166
|
p.length <= 4096 &&
|
|
@@ -212,7 +213,7 @@ function declaration(value, format) {
|
|
|
212
213
|
}
|
|
213
214
|
return value;
|
|
214
215
|
}
|
|
215
|
-
function checkLimit(limits) {
|
|
216
|
+
export function checkLimit(limits) {
|
|
216
217
|
if (
|
|
217
218
|
limits.schemaVersion !== 1 ||
|
|
218
219
|
limits.maximumStores !== 32 ||
|
|
@@ -258,7 +259,7 @@ export function captureRetained(root, destination, mode, limits) {
|
|
|
258
259
|
}
|
|
259
260
|
const names = fs.readdirSync(root).sort();
|
|
260
261
|
for (const id of names) {
|
|
261
|
-
if (["retained-root-lease.sqlite", RESTORE_FILE].includes(id)) continue;
|
|
262
|
+
if (["retained-root-lease.sqlite", RESTORE_FILE,RESTORE_MANIFEST].includes(id)) continue;
|
|
262
263
|
if (!uuid.test(id) || manifest.stores.length >= limits.maximumStores) throw Error("Unexpected retained root entry");
|
|
263
264
|
const dir = path.join(root, id);
|
|
264
265
|
privateRoot(dir);
|
|
@@ -267,30 +268,25 @@ export function captureRetained(root, destination, mode, limits) {
|
|
|
267
268
|
if (entry(path.join(dir, OPERATION))) throw Error("Retained operation is incomplete");
|
|
268
269
|
const b = binding(readJSON(path.join(dir, "store.json")), root, id);
|
|
269
270
|
const d = declaration(readJSON(path.join(dir, DECLARATION)), b.format);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
throw Error("Undeclared returned-store entry");
|
|
271
|
+
const retired = !!entry(path.join(dir, "store-returned.json"));
|
|
272
|
+
if (retired) {
|
|
273
273
|
const r = readJSON(path.join(dir, "store-returned.json"));
|
|
274
|
-
if (r.operationId !== b.operationId || r.generation !== b.generation
|
|
274
|
+
if (r.operationId !== b.operationId || r.generation !== b.generation)
|
|
275
275
|
throw Error("Uncertain returned store");
|
|
276
|
-
fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
|
|
277
|
-
manifest.entries.push({ path: id, type: "directory" });
|
|
278
|
-
for (const name of ["store.json", DECLARATION, "store-returned.json"]) copy(path.join(dir, name), id + "/" + name);
|
|
279
|
-
manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources: [], retired: true });
|
|
280
|
-
continue;
|
|
281
276
|
}
|
|
282
|
-
const allowed = new Set(["store.json", DECLARATION, "store-lease.sqlite", ...d.resources.map((x) => x.id)]);
|
|
277
|
+
const allowed = new Set(["store.json", DECLARATION, "store-lease.sqlite", ...(retired?["store-returned.json"]:[]), ...d.resources.map((x) => x.id)]);
|
|
283
278
|
if (fs.readdirSync(dir).some((n) => !allowed.has(n))) throw Error("Undeclared retained entry");
|
|
284
279
|
fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
|
|
285
280
|
manifest.entries.push({ path: id, type: "directory" });
|
|
286
281
|
copy(path.join(dir, "store.json"), id + "/store.json");
|
|
287
282
|
copy(path.join(dir, DECLARATION), id + "/" + DECLARATION);
|
|
283
|
+
if(retired)copy(path.join(dir,"store-returned.json"),id+"/store-returned.json");
|
|
288
284
|
const resources = [];
|
|
289
285
|
for (const r of d.resources) {
|
|
290
286
|
const p = path.join(dir, r.id),
|
|
291
287
|
s = entry(p);
|
|
292
288
|
if (!s) {
|
|
293
|
-
if (r.required) throw Error("Required retained resource missing");
|
|
289
|
+
if (r.required && !retired) throw Error("Required retained resource missing");
|
|
294
290
|
continue;
|
|
295
291
|
}
|
|
296
292
|
if ((r.kind === "directory") !== s.isDirectory()) throw Error("Retained resource kind mismatch");
|
|
@@ -298,7 +294,7 @@ export function captureRetained(root, destination, mode, limits) {
|
|
|
298
294
|
resources.push({ id: r.id, backup: r.backup ?? "payload", included });
|
|
299
295
|
if (included) copy(p, id + "/" + r.id);
|
|
300
296
|
}
|
|
301
|
-
manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources });
|
|
297
|
+
manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources, ...(retired?{retired:true}:{}) });
|
|
302
298
|
}
|
|
303
299
|
const raw = JSON.stringify(manifest);
|
|
304
300
|
if (Buffer.byteLength(raw) > limits.maximumManifestBytes) throw Error("Retained manifest limit");
|
|
@@ -363,7 +359,7 @@ function verifySnapshot(source, limits) {
|
|
|
363
359
|
if (stores.size !== m.stores.length || m.entries.some((e) => !stores.has(e.path.split("/")[0]))) throw Error("Undeclared snapshot store");
|
|
364
360
|
for (const st of m.stores) {
|
|
365
361
|
const b = binding(readJSON(path.join(source, st.storeId, "store.json")), m.sourceRoot, st.storeId);
|
|
366
|
-
declaration(readJSON(path.join(source, st.storeId, DECLARATION)), b.format);
|
|
362
|
+
const declared=declaration(readJSON(path.join(source, st.storeId, DECLARATION)), b.format);
|
|
367
363
|
if (st.retired) {
|
|
368
364
|
const r = readJSON(path.join(source, st.storeId, "store-returned.json"));
|
|
369
365
|
if (
|
|
@@ -371,7 +367,7 @@ function verifySnapshot(source, limits) {
|
|
|
371
367
|
r.generation !== b.generation ||
|
|
372
368
|
m.entries.some(
|
|
373
369
|
(e) =>
|
|
374
|
-
e.path.startsWith(st.storeId + "/") && !["store.json", DECLARATION, "store-returned.json"].includes(e.path.slice(st.storeId.length + 1)),
|
|
370
|
+
e.path.startsWith(st.storeId + "/") && !["store.json", DECLARATION, "store-returned.json",...declared.resources.map(r=>r.id)].includes(e.path.slice(st.storeId.length + 1).split("/")[0]),
|
|
375
371
|
)
|
|
376
372
|
)
|
|
377
373
|
throw Error("Invalid retired-store receipt");
|
|
@@ -397,6 +393,7 @@ export function restoreRetained(source, root, backupDigest, limits, afterPublish
|
|
|
397
393
|
if (new Set(ids).size !== ids.length || ids.some((id) => !uuid.test(id))) throw Error("Invalid snapshot store identities");
|
|
398
394
|
if (marker?.state === "complete") {
|
|
399
395
|
for (const id of ids) binding(readJSON(path.join(root, id, "store.json")), root, id);
|
|
396
|
+
persistRecoveryManifest(source,root,manifestDigest);
|
|
400
397
|
return { state: "complete", stores: ids.length };
|
|
401
398
|
}
|
|
402
399
|
if (!marker) {
|
|
@@ -404,10 +401,11 @@ export function restoreRetained(source, root, backupDigest, limits, afterPublish
|
|
|
404
401
|
marker = { protocol: 1, state: "applying", backupDigest, manifestDigest };
|
|
405
402
|
atomicJSON(markerFile, marker);
|
|
406
403
|
}
|
|
404
|
+
persistRecoveryManifest(source,root,manifestDigest);
|
|
407
405
|
const stage = path.join(root, "retained-restore-stage");
|
|
408
406
|
if (!entry(stage)) fs.mkdirSync(stage, { mode: 0o700 });
|
|
409
407
|
privateRoot(stage);
|
|
410
|
-
const allowed = new Set(["retained-root-lease.sqlite", RESTORE_FILE, "retained-restore-stage", ...ids]);
|
|
408
|
+
const allowed = new Set(["retained-root-lease.sqlite", RESTORE_FILE,RESTORE_MANIFEST, "retained-restore-stage", ...ids]);
|
|
411
409
|
if (fs.readdirSync(root).some((n) => !allowed.has(n))) throw Error("Unexpected restore target entry");
|
|
412
410
|
for (const e of m.entries) {
|
|
413
411
|
const id = e.path.split("/")[0],
|
|
@@ -486,8 +484,55 @@ export function retainedStorageMain() {
|
|
|
486
484
|
? captureRetained(root, out, modeOrDigest, limits)
|
|
487
485
|
: action === "restore"
|
|
488
486
|
? restoreRetained(root, out, modeOrDigest, limits)
|
|
487
|
+
: action === "verify-restored"
|
|
488
|
+
? verifyRestoredRetained(root,out,modeOrDigest,limits)
|
|
489
|
+
: action === "verify-root"
|
|
490
|
+
? verifyRetainedRecoveryRoot(root,modeOrDigest,out,limits)
|
|
489
491
|
: (() => {
|
|
490
492
|
throw Error("Unknown retained operation");
|
|
491
493
|
})();
|
|
492
494
|
process.stdout.write(JSON.stringify(result));
|
|
493
495
|
}
|
|
496
|
+
|
|
497
|
+
/** Offline proof tied to one verified backup, used before an RTT tree can activate. */
|
|
498
|
+
export function verifyRestoredRetained(source,root,backupDigest,limits){
|
|
499
|
+
checkLimit(limits);
|
|
500
|
+
const m=verifySnapshot(source,limits);
|
|
501
|
+
if(m.mode!=="include"||m.sourceRoot!==root||!hash.test(backupDigest))throw Error("Retained recovery scope differs");
|
|
502
|
+
return verifyRetainedRecoveryRoot(root,backupDigest,sha(path.join(source,"manifest.json")),limits);
|
|
503
|
+
}
|
|
504
|
+
function persistRecoveryManifest(source,root,digest){
|
|
505
|
+
const target=path.join(root,RESTORE_MANIFEST);
|
|
506
|
+
const temporary=target+".part";
|
|
507
|
+
if(entry(temporary)){owned(temporary);fs.unlinkSync(temporary);sync(root);}
|
|
508
|
+
if(entry(target)){if(sha(target)!==digest)throw Error("Retained recovery manifest differs");return;}
|
|
509
|
+
try{fs.copyFileSync(path.join(source,"manifest.json"),temporary,fs.constants.COPYFILE_EXCL);fs.chmodSync(temporary,0o600);sync(temporary);if(sha(temporary)!==digest)throw Error("Retained recovery manifest changed");fs.renameSync(temporary,target);sync(root);}finally{if(entry(temporary))fs.unlinkSync(temporary);}
|
|
510
|
+
}
|
|
511
|
+
export function verifyRetainedRecoveryRoot(root,backupDigest,digest,limits){
|
|
512
|
+
checkLimit(limits);
|
|
513
|
+
if(!hash.test(backupDigest)||!hash.test(digest))throw Error("Invalid retained recovery proof");
|
|
514
|
+
const release=rootLease(root,true,true);
|
|
515
|
+
try{
|
|
516
|
+
const marker=readJSON(path.join(root,RESTORE_FILE));
|
|
517
|
+
if(marker.state!=="complete"||marker.backupDigest!==backupDigest||marker.manifestDigest!==digest)throw Error("Matching completed retained recovery is required");
|
|
518
|
+
const file=path.join(root,RESTORE_MANIFEST);
|
|
519
|
+
if(sha(file)!==digest)throw Error("Retained recovery manifest differs");
|
|
520
|
+
const m=readJSON(file,limits.maximumManifestBytes);
|
|
521
|
+
if(m.schemaVersion!==1||m.kind!=="uns-retained-store-snapshot"||m.mode!=="include"||m.sourceRoot!==root||!Array.isArray(m.stores)||m.stores.length>limits.maximumStores||!Array.isArray(m.entries)||m.entries.length>limits.maximumEntries)throw Error("Invalid retained recovery manifest");
|
|
522
|
+
const ids=m.stores.map(s=>s.storeId);
|
|
523
|
+
if(new Set(ids).size!==ids.length||ids.some(id=>!uuid.test(id)))throw Error("Invalid recovered stores");
|
|
524
|
+
if(fs.readdirSync(root).some(n=>!["retained-root-lease.sqlite",RESTORE_FILE,RESTORE_MANIFEST,...ids].includes(n)))throw Error("Unexpected retained recovery root entry");
|
|
525
|
+
const seen=new Set();let total=0;
|
|
526
|
+
for(const e of m.entries){
|
|
527
|
+
if(!relative(e.path)||!ids.includes(e.path.split('/')[0])||seen.has(e.path)||!["file","directory"].includes(e.type))throw Error("Invalid retained recovery path");seen.add(e.path);
|
|
528
|
+
const p=path.join(root,e.path),s=owned(p,e.type==="directory");
|
|
529
|
+
if(e.type==="file"){total+=s.size;if(total>limits.maximumBytes||s.size>limits.maximumFileBytes||s.size!==e.size||sha(p)!==e.sha256)throw Error("Restored retained payload changed before activation");}
|
|
530
|
+
}
|
|
531
|
+
for(const id of ids){
|
|
532
|
+
const prefix=id+"/",dir=path.join(root,id),allowed=new Set(m.entries.filter(e=>e.path.startsWith(prefix)).map(e=>e.path.slice(prefix.length)));
|
|
533
|
+
if(entry(path.join(dir,"store-lease.sqlite"))){owned(path.join(dir,"store-lease.sqlite"));allowed.add("store-lease.sqlite");}
|
|
534
|
+
exactTree(dir,allowed);
|
|
535
|
+
}
|
|
536
|
+
return{state:"verified",stores:ids.length,backupDigest,manifestDigest:digest,root};
|
|
537
|
+
}finally{release();}
|
|
538
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { type RetainedCopyFingerprint } from "./retained-store-copy.mjs";
|
|
3
4
|
export declare const RETAINED_STORE_MANIFEST = "retained-store.manifest.json";
|
|
4
5
|
export declare const RETAINED_STORE_BINDING = "retained-store-binding.json";
|
|
5
6
|
export declare const RETAINED_STORE_GUARD = "retained-store-guard.json";
|
|
@@ -45,6 +46,7 @@ export type ResourceMove = {
|
|
|
45
46
|
kind: "file" | "directory";
|
|
46
47
|
device: number;
|
|
47
48
|
inode: number;
|
|
49
|
+
content?: RetainedCopyFingerprint;
|
|
48
50
|
};
|
|
49
51
|
export type StoreMovePlan = {
|
|
50
52
|
storeId: string;
|
|
@@ -54,6 +56,8 @@ export type StoreMovePlan = {
|
|
|
54
56
|
directory: string;
|
|
55
57
|
slots: RetainedStoreManifest["resources"];
|
|
56
58
|
moves: ResourceMove[];
|
|
59
|
+
transfer?: "copy-verified";
|
|
60
|
+
originalMoves?: ResourceMove[];
|
|
57
61
|
};
|
|
58
62
|
export declare function retainedStoreDigest(value: unknown): string;
|
|
59
63
|
export declare function retainedStoreEntry(file: string): fs.Stats | undefined;
|
|
@@ -62,10 +66,10 @@ export declare function readRetainedStoreJson(file: string): unknown;
|
|
|
62
66
|
export declare function syncRetainedDirectory(directory: string): void;
|
|
63
67
|
export declare function writeRetainedStoreJson(file: string, value: unknown): void;
|
|
64
68
|
export declare function readRetainedStoreManifest(packageDirectory: string): RetainedStoreManifest;
|
|
65
|
-
export declare function prepareRetainedStoreMove(source: string, root: string, storeId: string, manifest: RetainedStoreManifest): StoreMovePlan;
|
|
69
|
+
export declare function prepareRetainedStoreMove(source: string, root: string, storeId: string, manifest: RetainedStoreManifest, transfer?: "copy-verified", deferContent?: boolean): StoreMovePlan;
|
|
66
70
|
/** Caller durably records the complete plan and holds its resource reservation first.
|
|
67
71
|
* Each rename is atomic; the multi-resource operation is deliberately resumable. */
|
|
68
|
-
export declare function applyRetainedStoreMove(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void): void;
|
|
72
|
+
export declare function applyRetainedStoreMove(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void, onFile?: (file: string) => void, validateOnly?: boolean): void;
|
|
69
73
|
export declare function writeRetainedStoreBinding(instanceDirectory: string, binding: StoreBinding): void;
|
|
70
74
|
export declare function loadRetainedStoreBinding(instanceDirectory: string, operationId?: string): StoreBinding | undefined;
|
|
71
75
|
/** No application payload/schema inspection is performed by this helper. */
|
|
@@ -80,6 +84,7 @@ export declare const retainedStoreSelectionSchema: z.ZodObject<{
|
|
|
80
84
|
sourceInstanceId: z.ZodString;
|
|
81
85
|
targetVersion: z.ZodString;
|
|
82
86
|
targetInstanceId: z.ZodString;
|
|
87
|
+
transfer: z.ZodOptional<z.ZodLiteral<"copy-verified">>;
|
|
83
88
|
}, z.core.$strict>;
|
|
84
89
|
export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
85
90
|
action: z.ZodLiteral<"review">;
|
|
@@ -90,6 +95,7 @@ export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodO
|
|
|
90
95
|
sourceInstanceId: z.ZodString;
|
|
91
96
|
targetVersion: z.ZodString;
|
|
92
97
|
targetInstanceId: z.ZodString;
|
|
98
|
+
transfer: z.ZodOptional<z.ZodLiteral<"copy-verified">>;
|
|
93
99
|
}, z.core.$strict>;
|
|
94
100
|
}, z.core.$strict>, z.ZodObject<{
|
|
95
101
|
action: z.ZodLiteral<"get">;
|
|
@@ -104,12 +110,15 @@ export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodO
|
|
|
104
110
|
}, z.core.$strict>], "action">;
|
|
105
111
|
export type RetainedStoreRequest = z.infer<typeof retainedStoreRequestSchema>;
|
|
106
112
|
/** Capture mutable control files from the current stopped owner before persisting return intent. */
|
|
107
|
-
export declare function prepareRetainedStoreReturn(plan: StoreMovePlan): StoreMovePlan;
|
|
113
|
+
export declare function prepareRetainedStoreReturn(plan: StoreMovePlan, deferContent?: boolean): StoreMovePlan;
|
|
108
114
|
export declare function retainedStoreRoot(defaultBase: string, configured?: string): string;
|
|
109
115
|
export declare function openRetainedStoreLease(binding: StoreBinding): {
|
|
110
116
|
close: () => void;
|
|
111
117
|
};
|
|
112
118
|
export declare function openRetainedStoreMaintenance(directory: string): () => void;
|
|
119
|
+
export declare function prepareRetainedStoreMoveAsync(source: string, root: string, id: string, manifest: RetainedStoreManifest, transfer?: "copy-verified"): Promise<StoreMovePlan>;
|
|
120
|
+
export declare function prepareRetainedStoreReturnAsync(plan: StoreMovePlan): Promise<StoreMovePlan>;
|
|
121
|
+
export declare function applyRetainedStoreMoveAsync(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void): Promise<void>;
|
|
113
122
|
export declare const retainedStoreBackupSchema: z.ZodObject<{
|
|
114
123
|
schemaVersion: z.ZodLiteral<1>;
|
|
115
124
|
state: z.ZodEnum<{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"retained-store.d.ts","sourceRoot":"","sources":["../../src/runtime/retained-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"retained-store.d.ts","sourceRoot":"","sources":["../../src/runtime/retained-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,6 +3,9 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { available, maintenanceLease, runtimeLease } from "./retained-store-storage.mjs";
|
|
6
|
+
import { inspectRetainedCopy, applyRetainedCopy } from "./retained-store-copy.mjs";
|
|
7
|
+
import { retainedStoreJob } from "./retained-store-job.mjs";
|
|
8
|
+
const copyLimits = () => JSON.parse(fs.readFileSync(new URL("./retained-store-storage-contract.json", import.meta.url), "utf8"));
|
|
6
9
|
const segment = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/);
|
|
7
10
|
const relative = z
|
|
8
11
|
.string()
|
|
@@ -137,7 +140,7 @@ export function writeRetainedStoreJson(file, value) {
|
|
|
137
140
|
export function readRetainedStoreManifest(packageDirectory) {
|
|
138
141
|
return retainedStoreManifestSchema.parse(readRetainedStoreJson(path.join(packageDirectory, RETAINED_STORE_MANIFEST)));
|
|
139
142
|
}
|
|
140
|
-
export function prepareRetainedStoreMove(source, root, storeId, manifest) {
|
|
143
|
+
export function prepareRetainedStoreMove(source, root, storeId, manifest, transfer, deferContent = false) {
|
|
141
144
|
z.uuid().parse(storeId);
|
|
142
145
|
retainedStoreManifestSchema.parse(manifest);
|
|
143
146
|
assertRetainedStorePath(source);
|
|
@@ -151,8 +154,10 @@ export function prepareRetainedStoreMove(source, root, storeId, manifest) {
|
|
|
151
154
|
const rootStat = existingRoot ?? fs.statSync(path.dirname(root)), sourceStat = fs.statSync(source);
|
|
152
155
|
if (root === source || root.startsWith(source + path.sep) || source.startsWith(root + path.sep))
|
|
153
156
|
throw Error("Nested retained store roots");
|
|
154
|
-
if (
|
|
155
|
-
throw Error("
|
|
157
|
+
if (transfer !== undefined && transfer !== "copy-verified")
|
|
158
|
+
throw Error("Unsupported retained transfer");
|
|
159
|
+
if (!transfer && rootStat.dev !== sourceStat.dev)
|
|
160
|
+
throw Error("Retained store requires same filesystem; review an explicit verified copy");
|
|
156
161
|
const moves = [];
|
|
157
162
|
for (const resource of manifest.resources) {
|
|
158
163
|
const file = path.join(source, resource.path);
|
|
@@ -164,13 +169,16 @@ export function prepareRetainedStoreMove(source, root, storeId, manifest) {
|
|
|
164
169
|
continue;
|
|
165
170
|
}
|
|
166
171
|
if (s.uid !== process.getuid?.() ||
|
|
167
|
-
s.dev !== rootStat.dev ||
|
|
172
|
+
(!transfer && s.dev !== rootStat.dev) ||
|
|
168
173
|
s.isSymbolicLink() ||
|
|
169
174
|
(resource.kind === "file" ? !s.isFile() || s.nlink !== 1 : !s.isDirectory()))
|
|
170
175
|
throw Error("Unsafe retained resource");
|
|
171
|
-
moves.push({ id: resource.id, relative: resource.path, kind: resource.kind, device: s.dev, inode: s.ino
|
|
176
|
+
moves.push({ id: resource.id, relative: resource.path, kind: resource.kind, device: s.dev, inode: s.ino,
|
|
177
|
+
...(transfer && !deferContent ? { content: inspectRetainedCopy(file, resource.kind, copyLimits()) } : {}) });
|
|
172
178
|
}
|
|
173
|
-
|
|
179
|
+
if (transfer && (moves.reduce((n, m) => n + (m.content?.bytes ?? 0), 0) > copyLimits().maximumBytes || moves.reduce((n, m) => n + (m.content?.entries ?? 0), 0) > copyLimits().maximumEntries))
|
|
180
|
+
throw Error("Retained copy aggregate limit");
|
|
181
|
+
return { storeId, generation: randomUUID(), format: manifest.format, source, directory, slots: manifest.resources, moves, ...(transfer ? { transfer } : {}) };
|
|
174
182
|
}
|
|
175
183
|
function matches(file, move) {
|
|
176
184
|
assertRetainedStorePath(file);
|
|
@@ -184,7 +192,8 @@ function matches(file, move) {
|
|
|
184
192
|
}
|
|
185
193
|
/** Caller durably records the complete plan and holds its resource reservation first.
|
|
186
194
|
* Each rename is atomic; the multi-resource operation is deliberately resumable. */
|
|
187
|
-
export function applyRetainedStoreMove(plan, direction, afterMove) {
|
|
195
|
+
export function applyRetainedStoreMove(plan, direction, afterMove, onFile, validateOnly = false) {
|
|
196
|
+
const resourceMove = z.object({ id: segment, relative, kind: z.enum(["file", "directory"]), device: z.number().int().nonnegative(), inode: z.number().int().nonnegative(), content: z.object({ digest: z.string().regex(/^[a-f0-9]{64}$/), bytes: z.number().int().nonnegative().max(copyLimits().maximumBytes), entries: z.number().int().positive().max(copyLimits().maximumEntries) }).strict().optional() }).strict();
|
|
188
197
|
z.object({
|
|
189
198
|
storeId: z.uuid(),
|
|
190
199
|
generation: z.uuid(),
|
|
@@ -202,17 +211,9 @@ export function applyRetainedStoreMove(plan, direction, afterMove) {
|
|
|
202
211
|
})
|
|
203
212
|
.strict())
|
|
204
213
|
.max(16),
|
|
205
|
-
moves: z
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
id: segment,
|
|
209
|
-
relative,
|
|
210
|
-
kind: z.enum(["file", "directory"]),
|
|
211
|
-
device: z.number().int().nonnegative(),
|
|
212
|
-
inode: z.number().int().nonnegative(),
|
|
213
|
-
})
|
|
214
|
-
.strict())
|
|
215
|
-
.max(16),
|
|
214
|
+
moves: z.array(resourceMove).max(16),
|
|
215
|
+
transfer: z.literal("copy-verified").optional(),
|
|
216
|
+
originalMoves: z.array(resourceMove).max(16).optional(),
|
|
216
217
|
})
|
|
217
218
|
.strict()
|
|
218
219
|
.parse(plan);
|
|
@@ -234,6 +235,20 @@ export function applyRetainedStoreMove(plan, direction, afterMove) {
|
|
|
234
235
|
const directoryStat = fs.lstatSync(plan.directory);
|
|
235
236
|
if (!directoryStat.isDirectory() || directoryStat.uid !== process.getuid?.() || directoryStat.mode & 0o077)
|
|
236
237
|
throw Error("Unsafe retained store directory");
|
|
238
|
+
if (plan.transfer) {
|
|
239
|
+
if (plan.moves.some(m => !m.content) || plan.originalMoves?.some(m => !m.content))
|
|
240
|
+
throw Error("Verified copy requires reviewed content fingerprints");
|
|
241
|
+
for (const entries of [plan.moves, plan.originalMoves ?? []])
|
|
242
|
+
if (entries.reduce((n, m) => n + (m.content?.bytes ?? 0), 0) > copyLimits().maximumBytes || entries.reduce((n, m) => n + (m.content?.entries ?? 0), 0) > copyLimits().maximumEntries)
|
|
243
|
+
throw Error("Retained copy aggregate limit");
|
|
244
|
+
if ((plan.originalMoves ?? []).some(m => !manifest.resources.some(r => r.id === m.id && r.path === m.relative && r.kind === m.kind)))
|
|
245
|
+
throw Error("Original copy differs from declared resources");
|
|
246
|
+
if (!validateOnly)
|
|
247
|
+
applyRetainedCopy(plan, direction, copyLimits(), afterMove, onFile);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (plan.originalMoves)
|
|
251
|
+
throw Error("Original copy receipt requires verified transfer");
|
|
237
252
|
for (const move of plan.moves) {
|
|
238
253
|
const legacy = path.join(plan.source, move.relative), stable = path.join(plan.directory, move.id);
|
|
239
254
|
const [from, to] = direction === "adopt" ? [legacy, stable] : [stable, legacy];
|
|
@@ -314,6 +329,7 @@ export const retainedStoreSelectionSchema = z
|
|
|
314
329
|
sourceInstanceId: segment,
|
|
315
330
|
targetVersion: segment,
|
|
316
331
|
targetInstanceId: segment,
|
|
332
|
+
transfer: z.literal("copy-verified").optional(),
|
|
317
333
|
})
|
|
318
334
|
.strict()
|
|
319
335
|
.refine((x) => x.sourceVersion !== x.targetVersion || x.sourceInstanceId !== x.targetInstanceId, "Source and target must differ");
|
|
@@ -323,8 +339,25 @@ export const retainedStoreRequestSchema = z.discriminatedUnion("action", [
|
|
|
323
339
|
z.object({ action: z.enum(["adopt", "return"]), id: z.uuid(), reviewedChecksum: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
|
324
340
|
]);
|
|
325
341
|
/** Capture mutable control files from the current stopped owner before persisting return intent. */
|
|
326
|
-
export function prepareRetainedStoreReturn(plan) {
|
|
342
|
+
export function prepareRetainedStoreReturn(plan, deferContent = false) {
|
|
327
343
|
const moves = [];
|
|
344
|
+
if (plan.transfer) {
|
|
345
|
+
for (const slot of plan.slots) {
|
|
346
|
+
const stable = path.join(plan.directory, slot.id), legacy = path.join(plan.source, slot.path), a = retainedStoreEntry(stable), b = retainedStoreEntry(legacy);
|
|
347
|
+
if (a) {
|
|
348
|
+
assertRetainedStorePath(stable);
|
|
349
|
+
moves.push({ id: slot.id, relative: slot.path, kind: slot.kind, device: a.dev, inode: a.ino, ...(!deferContent ? { content: inspectRetainedCopy(stable, slot.kind, copyLimits()) } : {}) });
|
|
350
|
+
}
|
|
351
|
+
else if (b) {
|
|
352
|
+
const original = plan.moves.find(m => m.id === slot.id);
|
|
353
|
+
if (!original || !matches(legacy, original) || (!deferContent && retainedStoreDigest(inspectRetainedCopy(legacy, slot.kind, copyLimits())) !== retainedStoreDigest(original.content)))
|
|
354
|
+
throw Error("Original retained copy changed");
|
|
355
|
+
}
|
|
356
|
+
else if (slot.required)
|
|
357
|
+
throw Error("Current retained resource missing");
|
|
358
|
+
}
|
|
359
|
+
return { ...plan, moves, originalMoves: plan.moves };
|
|
360
|
+
}
|
|
328
361
|
for (const slot of plan.slots) {
|
|
329
362
|
const stable = path.join(plan.directory, slot.id), legacy = path.join(plan.source, slot.path);
|
|
330
363
|
assertRetainedStorePath(stable);
|
|
@@ -359,6 +392,28 @@ export function openRetainedStoreLease(binding) {
|
|
|
359
392
|
export function openRetainedStoreMaintenance(directory) {
|
|
360
393
|
return maintenanceLease(directory);
|
|
361
394
|
}
|
|
395
|
+
export async function prepareRetainedStoreMoveAsync(source, root, id, manifest, transfer) {
|
|
396
|
+
const plan = prepareRetainedStoreMove(source, root, id, manifest, transfer, !!transfer);
|
|
397
|
+
if (transfer)
|
|
398
|
+
for (const move of plan.moves)
|
|
399
|
+
move.content = await retainedStoreJob({ action: "inspect", path: path.join(source, move.relative), kind: move.kind, limits: copyLimits() });
|
|
400
|
+
return plan;
|
|
401
|
+
}
|
|
402
|
+
export async function prepareRetainedStoreReturnAsync(plan) {
|
|
403
|
+
const back = prepareRetainedStoreReturn(plan, !!plan.transfer);
|
|
404
|
+
if (plan.transfer)
|
|
405
|
+
for (const move of back.moves)
|
|
406
|
+
move.content = await retainedStoreJob({ action: "inspect", path: path.join(plan.directory, move.id), kind: move.kind, limits: copyLimits() });
|
|
407
|
+
return back;
|
|
408
|
+
}
|
|
409
|
+
export async function applyRetainedStoreMoveAsync(plan, direction, afterMove) {
|
|
410
|
+
if (!plan.transfer) {
|
|
411
|
+
applyRetainedStoreMove(plan, direction, afterMove);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
applyRetainedStoreMove(plan, direction, undefined, undefined, true);
|
|
415
|
+
await retainedStoreJob({ action: "apply", plan, direction, limits: copyLimits() }, afterMove);
|
|
416
|
+
}
|
|
362
417
|
export const retainedStoreBackupSchema = z
|
|
363
418
|
.object({
|
|
364
419
|
schemaVersion: z.literal(1),
|