@uns-kit/core 3.0.30 → 3.0.32
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 +45 -0
- package/dist/config/app-config.d.ts +1 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js.map +1 -1
- package/dist/runtime/mqtt-output-state-contract.d.ts +27 -0
- package/dist/runtime/mqtt-output-state-contract.d.ts.map +1 -0
- package/dist/runtime/mqtt-output-state-contract.js +32 -0
- package/dist/runtime/mqtt-output-state-contract.js.map +1 -0
- package/dist/runtime/mqtt-output-state-contract.json +8 -0
- package/dist/runtime/mqtt-output-state-program.d.mts +32 -0
- package/dist/runtime/mqtt-output-state-program.mjs +562 -0
- package/dist/runtime/process-group-configured-upgrade.d.ts +1 -0
- package/dist/runtime/process-group-configured-upgrade.d.ts.map +1 -1
- package/dist/runtime/process-group-configured-upgrade.js +21 -4
- package/dist/runtime/process-group-configured-upgrade.js.map +1 -1
- package/dist/runtime/process-group-mqtt-output.d.ts +63 -0
- package/dist/runtime/process-group-mqtt-output.d.ts.map +1 -0
- package/dist/runtime/process-group-mqtt-output.js +89 -0
- package/dist/runtime/process-group-mqtt-output.js.map +1 -0
- package/dist/runtime/process-group-upgrade.d.ts +85 -2
- package/dist/runtime/process-group-upgrade.d.ts.map +1 -1
- package/dist/runtime/process-group-upgrade.js +74 -9
- package/dist/runtime/process-group-upgrade.js.map +1 -1
- package/dist/uns-config/config-schema.d.ts +4 -0
- package/dist/uns-config/config-schema.d.ts.map +1 -1
- package/dist/uns-config/uns-core-schema.d.ts +8 -0
- package/dist/uns-config/uns-core-schema.d.ts.map +1 -1
- package/dist/uns-mqtt/publish-queue-contract.d.ts +13 -0
- package/dist/uns-mqtt/publish-queue-contract.d.ts.map +1 -1
- package/dist/uns-mqtt/publish-queue-contract.js +13 -0
- package/dist/uns-mqtt/publish-queue-contract.js.map +1 -1
- package/dist/uns-mqtt/publish-queue-store.d.ts +1 -0
- package/dist/uns-mqtt/publish-queue-store.d.ts.map +1 -1
- package/dist/uns-mqtt/publish-queue-store.js +38 -39
- package/dist/uns-mqtt/publish-queue-store.js.map +1 -1
- package/dist/uns-mqtt/uns-mqtt-proxy.d.ts.map +1 -1
- package/dist/uns-mqtt/uns-mqtt-proxy.js +2 -7
- package/dist/uns-mqtt/uns-mqtt-proxy.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// SDK-owned data-only runtime/snapshot/restore contract. Never imports an application.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url),
|
|
9
|
+
sha = (x) => crypto.createHash("sha256").update(x).digest("hex");
|
|
10
|
+
const guardName = ".mqtt-output-root.sqlite",
|
|
11
|
+
markerName = ".mqtt-output-restore.json",
|
|
12
|
+
stageName = ".mqtt-output-restore-stage";
|
|
13
|
+
const safeName = /^[a-z][a-z0-9-]{0,99}$/,
|
|
14
|
+
hex = /^[a-f0-9]{64}$/,
|
|
15
|
+
uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
16
|
+
const exactKeys = (value, keys) =>
|
|
17
|
+
value &&
|
|
18
|
+
typeof value === "object" &&
|
|
19
|
+
!Array.isArray(value) &&
|
|
20
|
+
Object.keys(value).length === keys.length &&
|
|
21
|
+
keys.every((k) => Object.hasOwn(value, k));
|
|
22
|
+
const fail = () => {
|
|
23
|
+
throw Error("MQTT output state validation failed; preserve storage and review the operation");
|
|
24
|
+
};
|
|
25
|
+
const sqlite = () => require("node:sqlite");
|
|
26
|
+
function hashFile(file) {
|
|
27
|
+
const hash = crypto.createHash("sha256"),
|
|
28
|
+
buffer = Buffer.allocUnsafe(65536),
|
|
29
|
+
fd = fs.openSync(file, "r");
|
|
30
|
+
try {
|
|
31
|
+
let n;
|
|
32
|
+
while ((n = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, n));
|
|
33
|
+
return hash.digest("hex");
|
|
34
|
+
} finally {
|
|
35
|
+
fs.closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function outputStateLimits(value) {
|
|
39
|
+
const expected = {
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
maximumQueues: 32,
|
|
42
|
+
maximumRecordsPerQueue: 100000,
|
|
43
|
+
maximumManifestBytes: 262144,
|
|
44
|
+
maximumSnapshotBytes: 536870912,
|
|
45
|
+
maximumCapsuleBytes: 1073741824,
|
|
46
|
+
};
|
|
47
|
+
if (!value || Object.keys(value).length !== Object.keys(expected).length || Object.entries(expected).some(([k, v]) => value[k] !== v)) fail();
|
|
48
|
+
return Object.freeze({ ...expected });
|
|
49
|
+
}
|
|
50
|
+
function check(p, directory = false, optional = false) {
|
|
51
|
+
let s;
|
|
52
|
+
try {
|
|
53
|
+
s = fs.lstatSync(p);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (optional && e.code === "ENOENT") return;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
s.isSymbolicLink() ||
|
|
60
|
+
s.uid !== process.getuid?.() ||
|
|
61
|
+
s.mode & 0o077 ||
|
|
62
|
+
(!directory && (!s.isFile() || s.nlink !== 1)) ||
|
|
63
|
+
(directory && !s.isDirectory())
|
|
64
|
+
)
|
|
65
|
+
fail();
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
function noLinks(p) {
|
|
69
|
+
for (let q = p; q !== "/"; q = path.dirname(q)) {
|
|
70
|
+
try {
|
|
71
|
+
if (fs.lstatSync(q).isSymbolicLink()) fail();
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e.code !== "ENOENT") throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function privateJson(p) {
|
|
78
|
+
const s = check(p);
|
|
79
|
+
if (s.size > 262144) fail();
|
|
80
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
81
|
+
}
|
|
82
|
+
function atomicJson(p, value) {
|
|
83
|
+
const temp = p + ".writing-" + crypto.randomUUID();
|
|
84
|
+
let fd;
|
|
85
|
+
try {
|
|
86
|
+
fd = fs.openSync(temp, "wx", 0o600);
|
|
87
|
+
fs.writeFileSync(fd, JSON.stringify(value) + "\n");
|
|
88
|
+
fs.fsyncSync(fd);
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
fd = undefined;
|
|
91
|
+
fs.renameSync(temp, p);
|
|
92
|
+
sync(path.dirname(p));
|
|
93
|
+
} finally {
|
|
94
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
95
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function sync(directory) {
|
|
99
|
+
const fd = fs.openSync(directory, "r");
|
|
100
|
+
try {
|
|
101
|
+
fs.fsyncSync(fd);
|
|
102
|
+
} finally {
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function assertOutputStateRoot(root) {
|
|
107
|
+
if (process.platform !== "linux" || typeof root !== "string" || root !== path.resolve(root) || !root.startsWith("/var/lib/")) fail();
|
|
108
|
+
noLinks(root);
|
|
109
|
+
check(root, true);
|
|
110
|
+
const mount = fs
|
|
111
|
+
.readFileSync("/proc/self/mountinfo", "utf8")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((l) => l.split(" "))
|
|
114
|
+
.find((f) => f[4] === root);
|
|
115
|
+
if (!mount || !["ext4", "xfs", "btrfs", "zfs"].includes(mount[mount.indexOf("-") + 1])) fail();
|
|
116
|
+
}
|
|
117
|
+
export function outputStateRelative(root, directory) {
|
|
118
|
+
if (directory !== path.resolve(directory)) fail();
|
|
119
|
+
const rel = path.relative(root, directory).split(path.sep);
|
|
120
|
+
if (rel.length !== 2 || rel.some((x) => !safeName.test(x))) fail();
|
|
121
|
+
return rel.join("/");
|
|
122
|
+
}
|
|
123
|
+
function rootLease(root, exclusive = false) {
|
|
124
|
+
assertOutputStateRoot(root);
|
|
125
|
+
const file = path.join(root, guardName);
|
|
126
|
+
try {
|
|
127
|
+
fs.closeSync(fs.openSync(file, "wx", 0o600));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (e.code !== "EEXIST") throw e;
|
|
130
|
+
}
|
|
131
|
+
check(file);
|
|
132
|
+
check(file + "-journal", false, true);
|
|
133
|
+
const { DatabaseSync } = sqlite(),
|
|
134
|
+
db = new DatabaseSync(file);
|
|
135
|
+
try {
|
|
136
|
+
db.exec("PRAGMA busy_timeout=3000; PRAGMA synchronous=FULL");
|
|
137
|
+
if (db.prepare("PRAGMA journal_mode").get().journal_mode !== "delete") fail();
|
|
138
|
+
// Concurrent first owners serialize initialization, then retain shared read leases.
|
|
139
|
+
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'").get()) {
|
|
140
|
+
db.exec("PRAGMA busy_timeout=3000; BEGIN IMMEDIATE");
|
|
141
|
+
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'").get()) {
|
|
142
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length) fail();
|
|
143
|
+
db.exec("CREATE TABLE metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL)");
|
|
144
|
+
db.prepare("INSERT INTO metadata VALUES(?,?)").run("schema", "mqtt-output-root-1");
|
|
145
|
+
db.prepare("INSERT INTO metadata VALUES(?,?)").run("id", crypto.randomUUID());
|
|
146
|
+
db.exec("COMMIT");
|
|
147
|
+
sync(root);
|
|
148
|
+
} else db.exec("ROLLBACK");
|
|
149
|
+
}
|
|
150
|
+
db.exec(exclusive ? "BEGIN EXCLUSIVE" : "BEGIN");
|
|
151
|
+
const meta = Object.fromEntries(
|
|
152
|
+
db
|
|
153
|
+
.prepare("SELECT key,value FROM metadata")
|
|
154
|
+
.all()
|
|
155
|
+
.map((r) => [r.key, r.value]),
|
|
156
|
+
);
|
|
157
|
+
if (!exactKeys(meta, ["schema", "id"]) || meta.schema !== "mqtt-output-root-1" || !uuid.test(meta.id)) fail();
|
|
158
|
+
return { db, rootId: meta.id, close: () => db.close() };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
db.close();
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function openManagedOutputRoot(directory, root) {
|
|
165
|
+
if (!root) return undefined;
|
|
166
|
+
assertOutputStateRoot(root);
|
|
167
|
+
const relative = outputStateRelative(root, directory);
|
|
168
|
+
noLinks(directory);
|
|
169
|
+
for (let p = path.dirname(directory); p !== root; p = path.dirname(p)) check(p, true, true);
|
|
170
|
+
const lease = rootLease(root);
|
|
171
|
+
try {
|
|
172
|
+
const marker = path.join(root, markerName);
|
|
173
|
+
if (fs.existsSync(marker)) {
|
|
174
|
+
const m = privateJson(marker);
|
|
175
|
+
if (
|
|
176
|
+
!exactKeys(m, ["schemaVersion", "state", "sourceSha", "rootId"]) ||
|
|
177
|
+
m.schemaVersion !== 1 ||
|
|
178
|
+
m.state !== "complete" ||
|
|
179
|
+
!hex.test(m.sourceSha) ||
|
|
180
|
+
m.rootId !== lease.rootId
|
|
181
|
+
)
|
|
182
|
+
throw Error("MQTT output restoration is incomplete; reconcile the same restore before starting");
|
|
183
|
+
}
|
|
184
|
+
return { rootId: lease.rootId, relative, schema: "2", close: lease.close };
|
|
185
|
+
} catch (e) {
|
|
186
|
+
lease.close();
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Read-only reviewed handoff evidence. Never initializes, deletes or rebinds a queue. */
|
|
191
|
+
export function inspectManagedOutputQueue(root, directory, identity, limits, requireStopped = true) {
|
|
192
|
+
outputStateLimits(limits);
|
|
193
|
+
assertOutputStateRoot(root);
|
|
194
|
+
const relative = outputStateRelative(root, directory);
|
|
195
|
+
noLinks(directory);
|
|
196
|
+
const { DatabaseSync } = sqlite();
|
|
197
|
+
const file = path.join(root, guardName);
|
|
198
|
+
check(file);
|
|
199
|
+
check(file + "-journal", false, true);
|
|
200
|
+
const guard = new DatabaseSync(file, { readOnly: true });
|
|
201
|
+
let owner;
|
|
202
|
+
try {
|
|
203
|
+
guard.exec("PRAGMA busy_timeout=1000; BEGIN");
|
|
204
|
+
const meta = Object.fromEntries(guard.prepare("SELECT key,value FROM metadata").all().map(r => [r.key,r.value]));
|
|
205
|
+
if (!exactKeys(meta, ["schema", "id"]) || meta.schema !== "mqtt-output-root-1" || !uuid.test(meta.id)) fail();
|
|
206
|
+
const marker = path.join(root, markerName);
|
|
207
|
+
if (fs.existsSync(marker)) {
|
|
208
|
+
const m = privateJson(marker);
|
|
209
|
+
if (!exactKeys(m,["schemaVersion","state","sourceSha","rootId"]) || m.schemaVersion !== 1 ||
|
|
210
|
+
m.state !== "complete" || !hex.test(m.sourceSha) || m.rootId !== meta.id) fail();
|
|
211
|
+
}
|
|
212
|
+
if (fs.existsSync(path.join(root, stageName))) fail();
|
|
213
|
+
check(path.dirname(directory), true, true);
|
|
214
|
+
const base = {rootId: meta.id, relative, identity};
|
|
215
|
+
if (!check(directory, true, true)) return {...base, records:0, bytes:0, exists:false, ownerActive:false};
|
|
216
|
+
const entries = fs.opendirSync(directory);
|
|
217
|
+
const names = [];
|
|
218
|
+
try { let e; while ((e=entries.readSync())) { if (names.length >= 5) fail(); names.push(e.name); } }
|
|
219
|
+
finally { entries.closeSync(); }
|
|
220
|
+
if (names.length === 0) return {...base, records:0, bytes:0, exists:false, ownerActive:false};
|
|
221
|
+
if (names.some(n => !["mqtt-outbox.sqlite","mqtt-outbox.sqlite-wal","mqtt-outbox.sqlite-shm","mqtt-outbox-owner.sqlite","mqtt-outbox-owner.sqlite-journal"].includes(n))) fail();
|
|
222
|
+
let physicalBytes=0;
|
|
223
|
+
for (const name of names) { physicalBytes+=check(path.join(directory,name)).size; if(physicalBytes>limits.maximumSnapshotBytes) fail(); }
|
|
224
|
+
const ownerFile = path.join(directory,"mqtt-outbox-owner.sqlite");
|
|
225
|
+
check(ownerFile);
|
|
226
|
+
// Existing writer's exclusive lease must be released, even when usage is zero.
|
|
227
|
+
let ownerActive=false;
|
|
228
|
+
owner = new DatabaseSync(ownerFile);
|
|
229
|
+
try { owner.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE"); }
|
|
230
|
+
catch(e) { if(requireStopped || e.errcode!==5) throw e; ownerActive=true; }
|
|
231
|
+
|
|
232
|
+
const dbFile = path.join(directory,"mqtt-outbox.sqlite");
|
|
233
|
+
if (check(dbFile).size > limits.maximumSnapshotBytes) fail();
|
|
234
|
+
const status = inspectDatabase(dbFile,{rootId:meta.id,relative},limits);
|
|
235
|
+
if (status.identity !== identity) fail();
|
|
236
|
+
return {...base, records:status.records, bytes:status.bytes, exists:true, ownerActive};
|
|
237
|
+
} finally { owner?.close(); guard.close(); }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function validateOutputRow(row) {
|
|
241
|
+
if (typeof row.record !== "string" || sha(row.record) !== row.sha256) throw Error("MQTT outbox integrity check failed; pending work preserved");
|
|
242
|
+
const r = JSON.parse(row.record);
|
|
243
|
+
if (
|
|
244
|
+
!exactKeys(r, ["seq", "id", "topic", "message", "options", "bytes"]) ||
|
|
245
|
+
r.seq !== Number(row.seq) ||
|
|
246
|
+
r.id !== row.id ||
|
|
247
|
+
r.bytes !== Number(row.bytes) ||
|
|
248
|
+
!Number.isSafeInteger(r.seq) ||
|
|
249
|
+
r.seq < 1 ||
|
|
250
|
+
typeof r.id !== "string" ||
|
|
251
|
+
!r.id ||
|
|
252
|
+
r.id.length > 200 ||
|
|
253
|
+
typeof r.topic !== "string" ||
|
|
254
|
+
!r.topic ||
|
|
255
|
+
/[\0+#]/.test(r.topic) ||
|
|
256
|
+
Buffer.byteLength(r.topic) > 65535 ||
|
|
257
|
+
typeof r.message !== "string" ||
|
|
258
|
+
!r.options ||
|
|
259
|
+
![1, 2].includes(r.options.qos) ||
|
|
260
|
+
typeof r.options.retain !== "boolean" ||
|
|
261
|
+
Object.keys(r.options).some((k) => !["qos", "retain"].includes(k)) ||
|
|
262
|
+
r.bytes !== Buffer.byteLength(r.topic) + Buffer.byteLength(r.message) + 256
|
|
263
|
+
)
|
|
264
|
+
fail();
|
|
265
|
+
return r;
|
|
266
|
+
}
|
|
267
|
+
function inspectDatabase(file, binding, limits) {
|
|
268
|
+
check(file);
|
|
269
|
+
const { DatabaseSync } = sqlite(),
|
|
270
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
271
|
+
try {
|
|
272
|
+
db.exec("PRAGMA busy_timeout=1000; BEGIN");
|
|
273
|
+
if (db.prepare("PRAGMA quick_check").get().quick_check !== "ok") fail();
|
|
274
|
+
const meta = Object.fromEntries(
|
|
275
|
+
db
|
|
276
|
+
.prepare("SELECT key,value FROM metadata")
|
|
277
|
+
.all()
|
|
278
|
+
.map((r) => [r.key, r.value]),
|
|
279
|
+
);
|
|
280
|
+
if (
|
|
281
|
+
meta.schema !== "2" ||
|
|
282
|
+
meta.rootId !== binding.rootId ||
|
|
283
|
+
meta.relative !== binding.relative ||
|
|
284
|
+
!hex.test(meta.identity) ||
|
|
285
|
+
Object.keys(meta).some((k) => !["schema", "rootId", "relative", "identity"].includes(k))
|
|
286
|
+
)
|
|
287
|
+
fail();
|
|
288
|
+
let records = 0,
|
|
289
|
+
bytes = 0,
|
|
290
|
+
lastSeq = 0;
|
|
291
|
+
for (const row of db.prepare("SELECT seq,id,record,bytes,sha256 FROM pending ORDER BY seq").iterate()) {
|
|
292
|
+
const r = validateOutputRow(row);
|
|
293
|
+
records++;
|
|
294
|
+
bytes += r.bytes;
|
|
295
|
+
lastSeq = r.seq;
|
|
296
|
+
if (records > limits.maximumRecordsPerQueue || bytes > limits.maximumSnapshotBytes) fail();
|
|
297
|
+
}
|
|
298
|
+
return { identity: meta.identity, records, bytes, lastSeq };
|
|
299
|
+
} finally {
|
|
300
|
+
db.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function manifestBytes(directory) {
|
|
304
|
+
const file = path.join(directory, "manifest.json");
|
|
305
|
+
if (check(file).size > 262144) fail();
|
|
306
|
+
return fs.readFileSync(file);
|
|
307
|
+
}
|
|
308
|
+
export function validateOutputSnapshot(directory, suppliedLimits) {
|
|
309
|
+
const limits = outputStateLimits(suppliedLimits);
|
|
310
|
+
noLinks(directory);
|
|
311
|
+
check(directory, true);
|
|
312
|
+
const data = manifestBytes(directory);
|
|
313
|
+
if (data.length > limits.maximumManifestBytes) fail();
|
|
314
|
+
const manifest = JSON.parse(data);
|
|
315
|
+
if (
|
|
316
|
+
!exactKeys(manifest, ["schemaVersion", "kind", "rootId", "capturedAt", "restorePolicy", "queues"]) ||
|
|
317
|
+
manifest.schemaVersion !== 1 ||
|
|
318
|
+
manifest.kind !== "uns-mqtt-output-state-snapshot" ||
|
|
319
|
+
!uuid.test(manifest.rootId) ||
|
|
320
|
+
typeof manifest.capturedAt !== "string" ||
|
|
321
|
+
!Number.isFinite(Date.parse(manifest.capturedAt)) ||
|
|
322
|
+
manifest.restorePolicy !== "offline-source-stop-confirmation-required-no-replay" ||
|
|
323
|
+
!Array.isArray(manifest.queues) ||
|
|
324
|
+
manifest.queues.length > limits.maximumQueues
|
|
325
|
+
)
|
|
326
|
+
fail();
|
|
327
|
+
const expected = new Set(["manifest.json"]),
|
|
328
|
+
directories = new Set();
|
|
329
|
+
let size = 0;
|
|
330
|
+
const seen = new Set();
|
|
331
|
+
for (const q of manifest.queues) {
|
|
332
|
+
if (
|
|
333
|
+
!exactKeys(q, ["path", "file", "size", "sha256", "identity", "records", "bytes", "lastSeq"]) ||
|
|
334
|
+
q.path !== outputStateRelative("/", "/" + q.path) ||
|
|
335
|
+
seen.has(q.path) ||
|
|
336
|
+
q.file !== q.path + "/mqtt-outbox.sqlite" ||
|
|
337
|
+
!hex.test(q.sha256) ||
|
|
338
|
+
!hex.test(q.identity)
|
|
339
|
+
)
|
|
340
|
+
fail();
|
|
341
|
+
seen.add(q.path);
|
|
342
|
+
directories.add(q.path.split("/")[0]);
|
|
343
|
+
directories.add(q.path);
|
|
344
|
+
const file = path.join(directory, q.file);
|
|
345
|
+
noLinks(file);
|
|
346
|
+
const s = check(file);
|
|
347
|
+
size += s.size;
|
|
348
|
+
if (s.size !== q.size || size > limits.maximumSnapshotBytes || hashFile(file) !== q.sha256) fail();
|
|
349
|
+
const actual = inspectDatabase(file, { rootId: manifest.rootId, relative: q.path }, limits);
|
|
350
|
+
if (["identity", "records", "bytes", "lastSeq"].some((k) => actual[k] !== q[k])) fail();
|
|
351
|
+
expected.add(q.file);
|
|
352
|
+
}
|
|
353
|
+
// No undeclared files, symlinks or SQLite sidecars enter a restore.
|
|
354
|
+
function walk(dir, rel = "") {
|
|
355
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
356
|
+
const n = rel ? rel + "/" + e.name : e.name;
|
|
357
|
+
if (e.isSymbolicLink()) fail();
|
|
358
|
+
if (e.isDirectory()) {
|
|
359
|
+
if (!directories.has(n)) fail();
|
|
360
|
+
check(path.join(dir, e.name), true);
|
|
361
|
+
walk(path.join(dir, e.name), n);
|
|
362
|
+
} else if (!expected.has(n)) fail();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
walk(directory);
|
|
366
|
+
return manifest;
|
|
367
|
+
}
|
|
368
|
+
async function capture(root, out, limits) {
|
|
369
|
+
check(out, true);
|
|
370
|
+
if (fs.readdirSync(out).length) fail();
|
|
371
|
+
const lease = rootLease(root);
|
|
372
|
+
const manifest = {
|
|
373
|
+
schemaVersion: 1,
|
|
374
|
+
kind: "uns-mqtt-output-state-snapshot",
|
|
375
|
+
rootId: lease.rootId,
|
|
376
|
+
capturedAt: new Date().toISOString(),
|
|
377
|
+
restorePolicy: "offline-source-stop-confirmation-required-no-replay",
|
|
378
|
+
queues: [],
|
|
379
|
+
};
|
|
380
|
+
let total = 0;
|
|
381
|
+
try {
|
|
382
|
+
const marker = path.join(root, markerName);
|
|
383
|
+
if (fs.existsSync(marker) && privateJson(marker).state !== "complete") fail();
|
|
384
|
+
for (const e of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
385
|
+
if ([guardName, guardName + "-journal", markerName].includes(e.name)) {
|
|
386
|
+
check(path.join(root, e.name));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (!e.isDirectory() || !safeName.test(e.name)) fail();
|
|
390
|
+
const ns = path.join(root, e.name);
|
|
391
|
+
check(ns, true);
|
|
392
|
+
for (const id of fs.readdirSync(ns).sort()) {
|
|
393
|
+
if (!safeName.test(id) || manifest.queues.length >= limits.maximumQueues) fail();
|
|
394
|
+
const relative = e.name + "/" + id,
|
|
395
|
+
source = path.join(ns, id);
|
|
396
|
+
noLinks(source);
|
|
397
|
+
check(source, true);
|
|
398
|
+
const allowed = new Set([
|
|
399
|
+
"mqtt-outbox.sqlite",
|
|
400
|
+
"mqtt-outbox.sqlite-wal",
|
|
401
|
+
"mqtt-outbox.sqlite-shm",
|
|
402
|
+
"mqtt-outbox-owner.sqlite",
|
|
403
|
+
"mqtt-outbox-owner.sqlite-journal",
|
|
404
|
+
]);
|
|
405
|
+
for (const f of fs.readdirSync(source)) {
|
|
406
|
+
if (!allowed.has(f)) fail();
|
|
407
|
+
const s = check(path.join(source, f));
|
|
408
|
+
if (s.size > limits.maximumSnapshotBytes) fail();
|
|
409
|
+
}
|
|
410
|
+
const main = path.join(source, "mqtt-outbox.sqlite"),
|
|
411
|
+
before = check(main);
|
|
412
|
+
const targetDir = path.join(out, relative);
|
|
413
|
+
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
414
|
+
const dest = path.join(targetDir, "mqtt-outbox.sqlite");
|
|
415
|
+
const { DatabaseSync, backup } = sqlite(),
|
|
416
|
+
db = new DatabaseSync(main, { readOnly: true });
|
|
417
|
+
try {
|
|
418
|
+
await backup(db, dest, {
|
|
419
|
+
rate: 128,
|
|
420
|
+
progress: ({ totalPages }) => {
|
|
421
|
+
if (totalPages * 4096 > limits.maximumSnapshotBytes) fail();
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
} finally {
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
fs.chmodSync(dest, 0o600);
|
|
428
|
+
const frozen = new (sqlite().DatabaseSync)(dest);
|
|
429
|
+
try {
|
|
430
|
+
frozen.exec("PRAGMA journal_mode=DELETE");
|
|
431
|
+
} finally {
|
|
432
|
+
frozen.close();
|
|
433
|
+
}
|
|
434
|
+
const after = check(main);
|
|
435
|
+
if (before.ino !== after.ino || before.dev !== after.dev) fail();
|
|
436
|
+
const s = check(dest);
|
|
437
|
+
total += s.size;
|
|
438
|
+
if (total > limits.maximumSnapshotBytes) fail();
|
|
439
|
+
const data = inspectDatabase(dest, { rootId: lease.rootId, relative }, limits);
|
|
440
|
+
manifest.queues.push({ path: relative, file: relative + "/mqtt-outbox.sqlite", size: s.size, sha256: hashFile(dest), ...data });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const encoded = JSON.stringify(manifest) + "\n";
|
|
444
|
+
if (Buffer.byteLength(encoded) > limits.maximumManifestBytes) fail();
|
|
445
|
+
fs.writeFileSync(path.join(out, "manifest.json"), encoded, { mode: 0o600 });
|
|
446
|
+
sync(out);
|
|
447
|
+
return manifest;
|
|
448
|
+
} finally {
|
|
449
|
+
lease.close();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function restore(source, root, limits, sourceStopped) {
|
|
453
|
+
if (sourceStopped !== "source-stopped") throw Error("Explicit confirmation of the stopped source is required");
|
|
454
|
+
const manifest = validateOutputSnapshot(source, limits),
|
|
455
|
+
sourceSha = sha(manifestBytes(source));
|
|
456
|
+
assertOutputStateRoot(root);
|
|
457
|
+
// Empty or same-operation target only. An exclusive root lease excludes live SDK writers.
|
|
458
|
+
for (const name of fs.readdirSync(root))
|
|
459
|
+
if (![guardName, guardName + "-journal"].includes(name) && !fs.existsSync(path.join(root, markerName)))
|
|
460
|
+
throw Error("MQTT restore target is not empty; existing state is preserved");
|
|
461
|
+
const lease = rootLease(root, true),
|
|
462
|
+
marker = path.join(root, markerName),
|
|
463
|
+
stage = path.join(root, stageName);
|
|
464
|
+
try {
|
|
465
|
+
let prior;
|
|
466
|
+
if (fs.existsSync(marker)) {
|
|
467
|
+
prior = privateJson(marker);
|
|
468
|
+
if (
|
|
469
|
+
!exactKeys(prior, ["schemaVersion", "state", "sourceSha", "rootId"]) ||
|
|
470
|
+
prior.schemaVersion !== 1 ||
|
|
471
|
+
prior.sourceSha !== sourceSha ||
|
|
472
|
+
!["applying", "complete"].includes(prior.state)
|
|
473
|
+
)
|
|
474
|
+
fail();
|
|
475
|
+
}
|
|
476
|
+
const allowed = new Set([guardName, guardName + "-journal", markerName, stageName, ...manifest.queues.map((q) => q.path.split("/")[0])]);
|
|
477
|
+
for (const e of fs.readdirSync(root)) {
|
|
478
|
+
if (!allowed.has(e) || (!prior && ![guardName, guardName + "-journal"].includes(e)))
|
|
479
|
+
throw Error("MQTT restore target is not empty; existing state is preserved");
|
|
480
|
+
}
|
|
481
|
+
// A resumed restore never adopts undeclared children inside a known namespace.
|
|
482
|
+
for (const ns of new Set(manifest.queues.map((q) => q.path.split("/")[0]))) {
|
|
483
|
+
const dir = path.join(root, ns);
|
|
484
|
+
if (fs.existsSync(dir)) {
|
|
485
|
+
noLinks(dir);
|
|
486
|
+
check(dir, true);
|
|
487
|
+
for (const id of fs.readdirSync(dir)) if (!manifest.queues.some((q) => q.path === ns + "/" + id)) fail();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!prior) {
|
|
491
|
+
atomicJson(marker, { schemaVersion: 1, state: "applying", sourceSha, rootId: manifest.rootId });
|
|
492
|
+
fs.mkdirSync(stage, { mode: 0o700 });
|
|
493
|
+
} else if (prior.rootId !== manifest.rootId) fail();
|
|
494
|
+
lease.db.prepare("UPDATE metadata SET value=? WHERE key='id'").run(manifest.rootId);
|
|
495
|
+
for (const q of manifest.queues) {
|
|
496
|
+
const target = path.join(root, q.path),
|
|
497
|
+
file = path.join(target, "mqtt-outbox.sqlite");
|
|
498
|
+
noLinks(target);
|
|
499
|
+
if (fs.existsSync(target)) {
|
|
500
|
+
check(target, true);
|
|
501
|
+
if (fs.readdirSync(target).some((x) => x !== "mqtt-outbox.sqlite") || hashFile(file) !== q.sha256) fail();
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (prior?.state === "complete") fail();
|
|
505
|
+
const staged = path.join(stage, q.path);
|
|
506
|
+
fs.mkdirSync(staged, { recursive: true, mode: 0o700 });
|
|
507
|
+
const copy = path.join(staged, "mqtt-outbox.sqlite");
|
|
508
|
+
if (fs.existsSync(copy)) {
|
|
509
|
+
check(copy);
|
|
510
|
+
if (hashFile(copy) !== q.sha256) fail();
|
|
511
|
+
} else {
|
|
512
|
+
fs.copyFileSync(path.join(source, q.file), copy, fs.constants.COPYFILE_EXCL);
|
|
513
|
+
fs.chmodSync(copy, 0o600);
|
|
514
|
+
const fd = fs.openSync(copy, "r");
|
|
515
|
+
fs.fsyncSync(fd);
|
|
516
|
+
fs.closeSync(fd);
|
|
517
|
+
}
|
|
518
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
519
|
+
fs.renameSync(staged, target);
|
|
520
|
+
sync(path.dirname(target));
|
|
521
|
+
}
|
|
522
|
+
// Every restored file is rechecked before the root is declared usable.
|
|
523
|
+
for (const q of manifest.queues) if (hashFile(path.join(root, q.file)) !== q.sha256) fail();
|
|
524
|
+
lease.db.exec("COMMIT");
|
|
525
|
+
if (fs.existsSync(stage)) {
|
|
526
|
+
for (const ns of fs.readdirSync(stage)) {
|
|
527
|
+
const dir = path.join(stage, ns);
|
|
528
|
+
check(dir, true);
|
|
529
|
+
if (fs.readdirSync(dir).length) fail();
|
|
530
|
+
fs.rmdirSync(dir);
|
|
531
|
+
}
|
|
532
|
+
fs.rmdirSync(stage);
|
|
533
|
+
}
|
|
534
|
+
atomicJson(marker, { schemaVersion: 1, state: "complete", sourceSha, rootId: manifest.rootId });
|
|
535
|
+
return {
|
|
536
|
+
schemaVersion: 1,
|
|
537
|
+
state: "complete",
|
|
538
|
+
sourceSha,
|
|
539
|
+
queues: manifest.queues.length,
|
|
540
|
+
records: manifest.queues.reduce((n, q) => n + q.records, 0),
|
|
541
|
+
};
|
|
542
|
+
} finally {
|
|
543
|
+
lease.close();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
export async function runOutputStateProgram(args) {
|
|
547
|
+
const [action, root, out, fixed, confirmation] = args,
|
|
548
|
+
limits = outputStateLimits(JSON.parse(fixed));
|
|
549
|
+
if (action === "capture") return capture(root, out, limits);
|
|
550
|
+
if (action === "validate") return validateOutputSnapshot(root, limits);
|
|
551
|
+
if (action === "restore") return restore(root, out, limits, confirmation);
|
|
552
|
+
fail();
|
|
553
|
+
}
|
|
554
|
+
export function outputStateMain(args = process.argv.slice(2)) {
|
|
555
|
+
return runOutputStateProgram(args)
|
|
556
|
+
.then((r) => process.stdout.write(JSON.stringify(r) + "\n"))
|
|
557
|
+
.catch(() => {
|
|
558
|
+
process.stderr.write("MQTT output state operation refused; preserve source and target for review\n");
|
|
559
|
+
process.exitCode = 1;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await outputStateMain();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PreparedGroupUpgrade, UpgradeConfigurations } from "./process-group-upgrade.js";
|
|
2
2
|
/** Same generation, different paired artifacts. Never initializes or rewrites the journal. */
|
|
3
3
|
export declare function reviewInstalledConfigurationGroupUpgrade(configs: UpgradeConfigurations, controllerName: string, stateRoot: string, excludedUnsFields?: readonly string[]): PreparedGroupUpgrade;
|
|
4
|
+
export declare function reviewInstalledConfigurationGroupMqttOutput(configs: UpgradeConfigurations, controllerName: string, stateRoot: string, excludedUnsFields?: readonly string[]): PreparedGroupUpgrade;
|
|
4
5
|
//# sourceMappingURL=process-group-configured-upgrade.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-group-configured-upgrade.d.ts","sourceRoot":"","sources":["../../src/runtime/process-group-configured-upgrade.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-group-configured-upgrade.d.ts","sourceRoot":"","sources":["../../src/runtime/process-group-configured-upgrade.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAGV,oBAAoB,EAEpB,qBAAqB,EACtB,MAAM,4BAA4B,CAAC;AAEpC,8FAA8F;AAC9F,wBAAgB,wCAAwC,CACtD,OAAO,EAAE,qBAAqB,EAC9B,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,iBAAiB,GAAE,SAAS,MAAM,EAAO,GACxC,oBAAoB,CAEtB;AACD,wBAAgB,2CAA2C,CACzD,OAAO,EAAE,qBAAqB,EAC9B,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,iBAAiB,GAAE,SAAS,MAAM,EAAO,GACxC,oBAAoB,CAYtB"}
|
|
@@ -4,9 +4,26 @@ import { configurationResourceHash } from "./configuration-group-contract.js";
|
|
|
4
4
|
import { authorizeProcessGroupRuntime, processGroupAuthorizationFile, validateProcessGroupPolicyCandidate } from "./process-group-authorization.js";
|
|
5
5
|
import { resolvedProcessGroupChannels } from "./process-group-contract.js";
|
|
6
6
|
import { describeInstalledProcessGroupGeneration, inspectGenerationJournal } from "./process-group-generation.js";
|
|
7
|
+
import { mqttOutputPortableConfiguration, reviewProcessGroupMqttOutput } from "./process-group-mqtt-output.js";
|
|
7
8
|
import { groupArtifactHash, groupChannelsHash, configurationAuthorizationHash as hash, readProcessGroupResource as read, sha256, } from "./process-group-resources.js";
|
|
8
9
|
/** Same generation, different paired artifacts. Never initializes or rewrites the journal. */
|
|
9
10
|
export function reviewInstalledConfigurationGroupUpgrade(configs, controllerName, stateRoot, excludedUnsFields = []) {
|
|
11
|
+
return reviewConfigurationGroup(configs, controllerName, stateRoot, excludedUnsFields, false);
|
|
12
|
+
}
|
|
13
|
+
export function reviewInstalledConfigurationGroupMqttOutput(configs, controllerName, stateRoot, excludedUnsFields = []) {
|
|
14
|
+
const mqttOutput = reviewProcessGroupMqttOutput(configs, process.env["UNS_MQTT_OUTPUT_STATE_ROOT"], stateRoot);
|
|
15
|
+
const result = reviewConfigurationGroup(configs, controllerName, stateRoot, excludedUnsFields, true);
|
|
16
|
+
return {
|
|
17
|
+
...result,
|
|
18
|
+
review: {
|
|
19
|
+
...result.review,
|
|
20
|
+
protocolVersion: 3,
|
|
21
|
+
mqttOutput,
|
|
22
|
+
delivery: { inputPausePossible: true, exactlyOnceOutput: false, pendingOutputBlocksRollback: true },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function reviewConfigurationGroup(configs, controllerName, stateRoot, excludedUnsFields, mqttOutput) {
|
|
10
27
|
const context = (c) => ({
|
|
11
28
|
packageRoot: c.packageRoot,
|
|
12
29
|
instanceDirectory: c.instanceDirectory,
|
|
@@ -19,12 +36,12 @@ export function reviewInstalledConfigurationGroupUpgrade(configs, controllerName
|
|
|
19
36
|
if (a.d.protocolVersion !== 2 || b.d.protocolVersion !== 2 || a.d.namespace !== b.d.namespace || hash(a.reference) !== hash(b.reference))
|
|
20
37
|
throw Error("Unchanged configuration generation required; review resource changes as a separate generation");
|
|
21
38
|
const pkg = (c) => JSON.parse(read(path.join(c.packageRoot, "package.json")).toString()), sourcePackage = pkg(source), targetPackage = pkg(target);
|
|
22
|
-
if (!sourcePackage.version || !targetPackage.version || sourcePackage.version === targetPackage.version)
|
|
39
|
+
if (!sourcePackage.version || !targetPackage.version || (!mqttOutput && sourcePackage.version === targetPackage.version))
|
|
23
40
|
throw Error("Two different installed paired versions required");
|
|
24
41
|
const old = authorizeProcessGroupRuntime(context(source));
|
|
25
42
|
const sourcePolicy = read(old.file, true), previous = configurationGroupAuthorizationSchema.parse(JSON.parse(sourcePolicy.toString()));
|
|
26
|
-
const portable = (raw) => {
|
|
27
|
-
const copy = structuredClone(raw), uns = { ...copy["uns"] };
|
|
43
|
+
const portable = (raw, role) => {
|
|
44
|
+
const copy = mqttOutput ? mqttOutputPortableConfiguration(raw, role) : structuredClone(raw), uns = { ...copy["uns"] };
|
|
28
45
|
for (const key of excludedUnsFields)
|
|
29
46
|
delete uns[key];
|
|
30
47
|
copy["uns"] = uns;
|
|
@@ -46,7 +63,7 @@ export function reviewInstalledConfigurationGroupUpgrade(configs, controllerName
|
|
|
46
63
|
(role === "receiver"
|
|
47
64
|
? g.normalized.runtimeRole !== "admission"
|
|
48
65
|
: g.normalized.runtimeRole !== undefined && g.normalized.runtimeRole !== "publisher") ||
|
|
49
|
-
portable(c.raw) !== portable(configs.source[role].raw) ||
|
|
66
|
+
portable(c.raw, role) !== portable(configs.source[role].raw, role) ||
|
|
50
67
|
groupChannelsHash(g.channels) !== groupChannelsHash(a.channels) ||
|
|
51
68
|
processGroupAuthorizationFile(context(c)) !== old.file)
|
|
52
69
|
throw Error("Exact paired configuration, resources, broker identity and retained journal required");
|