@camstack/system 1.2.471 → 1.2.473
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/addon-runner.js +202 -8
- package/dist/addon-runner.mjs +203 -9
- package/dist/addon-utils.mjs +1 -1
- package/dist/{agent-https-bH6bKDeK.js → agent-https-B9Q3-IKd.js} +2 -5
- package/dist/{agent-https-DHBvFZJS.mjs → agent-https-DOwn4pqL.mjs} +3 -6
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +4 -4
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +5 -5
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +5 -5
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-history/device-history.addon.js +1 -1
- package/dist/builtins/device-history/device-history.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +3 -3
- package/dist/builtins/keychain/index.mjs +3 -3
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.d.ts +9 -25
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +240 -39
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +237 -38
- package/dist/builtins/liveness-monitor/liveness-reporter.d.ts +28 -0
- package/dist/builtins/liveness-monitor/storage-root-access.d.ts +53 -0
- package/dist/builtins/local-auth/local-auth.addon.js +16 -2
- package/dist/builtins/local-auth/local-auth.addon.mjs +19 -5
- package/dist/builtins/local-auth/person-scopes.d.ts +7 -0
- package/dist/builtins/local-network/local-network.addon.js +2 -2
- package/dist/builtins/local-network/local-network.addon.mjs +2 -2
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +10 -10
- package/dist/builtins/platform-probe/accelerator-facts-probe.d.ts +24 -0
- package/dist/builtins/platform-probe/index.js +110 -10
- package/dist/builtins/platform-probe/index.mjs +116 -17
- package/dist/builtins/platform-probe/platform-scorer.d.ts +10 -2
- package/dist/builtins/record-entries/index.js +1 -1
- package/dist/builtins/record-entries/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +20 -20
- package/dist/builtins/sqlite-storage/index.js +1 -1
- package/dist/builtins/sqlite-storage/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +3 -3
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +3 -3
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +3 -3
- package/dist/builtins/system-config/system-config.addon.mjs +115 -115
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{composition-sources-B5uJZXZ1.js → composition-sources-Dj_nInUQ.js} +1 -1
- package/dist/{composition-sources-DRw_vBKH.mjs → composition-sources-cwD35HB0.mjs} +1 -1
- package/dist/{constants-CToE4OFp.js → constants-XkWSVvgY.js} +1 -1
- package/dist/{constants-5lA8Z9d8.mjs → constants-p5mPM1iL.mjs} +1 -1
- package/dist/{coral-accelerators-BW3OcrLD.mjs → coral-accelerators-DBlgWB8k.mjs} +6 -6
- package/dist/{custom-action-registry-BGYDjTqY.mjs → custom-action-registry-DZSCLBuT.mjs} +37 -37
- package/dist/{device-ca-nOCYVFS4.js → device-ca-BXzQ1pYj.js} +1 -1
- package/dist/{device-ca-URrs1lxK.mjs → device-ca-DyUtcpf4.mjs} +1 -1
- package/dist/{dist-BjcWEfRy.js → dist-Bmah6waF.js} +209 -266
- package/dist/{dist-5XUG7iYJ.mjs → dist-C9lbdBL5.mjs} +204 -267
- package/dist/file-host-worker.js +1 -1
- package/dist/file-host-worker.mjs +1 -1
- package/dist/{hub-mdns-ciao-zD3JojB_.mjs → hub-mdns-ciao-BemeCNGe.mjs} +2 -2
- package/dist/{hub-mdns-ciao-vEWhJ_SD.js → hub-mdns-ciao-CUr6ewsX.js} +2 -2
- package/dist/index.js +236 -16
- package/dist/index.mjs +455 -235
- package/dist/kernel/hwaccel/hwaccel-resolver.d.ts +24 -4
- package/dist/kernel/hwaccel/vaapi-init-probe.d.ts +29 -0
- package/dist/kernel/moleculer/runner-process-group.d.ts +4 -0
- package/dist/{restore-archive-CN1pJTRn.mjs → restore-archive-C0aohmAK.mjs} +94 -94
- package/dist/{restore-archive-B64m2nAj.js → restore-archive-DbPmyjiM.js} +1 -1
- package/dist/{retired-settings-keys-C_Kjp-Bj.mjs → retired-settings-keys-CWp7ZSeA.mjs} +8 -0
- package/dist/{retired-settings-keys-CGUqX-Oz.js → retired-settings-keys-DorakcxV.js} +8 -0
- package/dist/{wal-maintenance-BNXVX-ot.js → wal-maintenance-BT8Ns0W3.js} +0 -0
- package/dist/{wal-maintenance-Bl2I-ZcR.mjs → wal-maintenance-BuB731DK.mjs} +0 -0
- package/package.json +6 -6
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
require("../../chunk-Cek0wNdY.js");
|
|
3
|
-
const require_dist = require("../../dist-
|
|
2
|
+
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
3
|
+
const require_dist = require("../../dist-Bmah6waF.js");
|
|
4
|
+
let node_fs_promises = require("node:fs/promises");
|
|
5
|
+
node_fs_promises = require_chunk.__toESM(node_fs_promises);
|
|
6
|
+
let node_path = require("node:path");
|
|
7
|
+
node_path = require_chunk.__toESM(node_path);
|
|
8
|
+
let node_fs = require("node:fs");
|
|
4
9
|
//#region src/builtins/liveness-monitor/liveness-checks.ts
|
|
5
10
|
var NO_DEVICES = "liveness:no-devices";
|
|
6
11
|
var ALL_OFFLINE = "liveness:all-devices-offline";
|
|
@@ -52,6 +57,198 @@ function isLivenessFindingId(id) {
|
|
|
52
57
|
return id === NO_DEVICES || id === ALL_OFFLINE || id.startsWith(NO_FOOTAGE_PREFIX);
|
|
53
58
|
}
|
|
54
59
|
//#endregion
|
|
60
|
+
//#region src/builtins/liveness-monitor/liveness-reporter.ts
|
|
61
|
+
/**
|
|
62
|
+
* Announce new liveness faults and their recoveries — never an unchanged fault.
|
|
63
|
+
*
|
|
64
|
+
* One ERROR line and one `SystemLivenessFailed` event per NEW finding id (the
|
|
65
|
+
* alert centre turns it into one persistent alert), one INFO line and one
|
|
66
|
+
* `SystemLivenessRecovered` when it is gone. Several families of checks run on
|
|
67
|
+
* different cadences (the device checks wait out the boot grace, the storage
|
|
68
|
+
* check does not), so a recovery is judged only within the family that was
|
|
69
|
+
* just evaluated: a storage check that found nothing must not "recover" a
|
|
70
|
+
* device finding it never looked at.
|
|
71
|
+
*/
|
|
72
|
+
/** Event source stamped on every emission — the addon that observed it. */
|
|
73
|
+
var LIVENESS_SOURCE = {
|
|
74
|
+
type: "addon",
|
|
75
|
+
id: "liveness-monitor"
|
|
76
|
+
};
|
|
77
|
+
var LivenessReporter = class {
|
|
78
|
+
deps;
|
|
79
|
+
/** Finding ids currently reported, so recovery can be detected and announced. */
|
|
80
|
+
active = /* @__PURE__ */ new Set();
|
|
81
|
+
constructor(deps) {
|
|
82
|
+
this.deps = deps;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* @param findings what the family found this evaluation
|
|
86
|
+
* @param inFamily which ids the family owns — only those can recover here
|
|
87
|
+
*/
|
|
88
|
+
report(findings, inFamily) {
|
|
89
|
+
const current = new Set(findings.map((f) => f.id));
|
|
90
|
+
for (const finding of findings) {
|
|
91
|
+
if (this.active.has(finding.id)) continue;
|
|
92
|
+
this.active.add(finding.id);
|
|
93
|
+
this.deps.logger.error(`liveness: ${finding.title} — ${finding.message}`, {
|
|
94
|
+
...finding.deviceId !== void 0 ? { tags: { deviceId: finding.deviceId } } : {},
|
|
95
|
+
meta: {
|
|
96
|
+
findingId: finding.id,
|
|
97
|
+
severity: finding.severity
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
this.deps.emit(require_dist.createEvent(require_dist.EventCategory.SystemLivenessFailed, LIVENESS_SOURCE, {
|
|
101
|
+
findingId: finding.id,
|
|
102
|
+
severity: finding.severity,
|
|
103
|
+
title: finding.title,
|
|
104
|
+
message: finding.message,
|
|
105
|
+
...finding.deviceId !== void 0 ? { deviceId: finding.deviceId } : {},
|
|
106
|
+
...finding.deviceName !== void 0 ? { name: finding.deviceName } : {}
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
for (const id of [...this.active]) {
|
|
110
|
+
if (current.has(id) || !inFamily(id)) continue;
|
|
111
|
+
this.active.delete(id);
|
|
112
|
+
this.deps.logger.info(`liveness: recovered — ${id}`, { meta: { findingId: id } });
|
|
113
|
+
this.deps.emit(require_dist.createEvent(require_dist.EventCategory.SystemLivenessRecovered, LIVENESS_SOURCE, { findingId: id }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/builtins/liveness-monitor/storage-root-access.ts
|
|
119
|
+
/**
|
|
120
|
+
* Can this node WRITE where its storage locations say media goes?
|
|
121
|
+
*
|
|
122
|
+
* 2026-10-23, a fresh install of the dev hub: the bind mounted at
|
|
123
|
+
* `/recordings` was `root:root 0755` while the hub runs as uid 1001. Nothing
|
|
124
|
+
* said so at boot; the first sign was an `EACCES` on `/recordings/artifacts`,
|
|
125
|
+
* and recordings would have failed one write at a time. A root the process
|
|
126
|
+
* cannot write is a fact known the moment the node starts, so it is reported
|
|
127
|
+
* then — once, naming the path, its owner, its mode and the uid — instead of
|
|
128
|
+
* as a stream of per-write errors.
|
|
129
|
+
*
|
|
130
|
+
* Only a location this node may WRITE (`mayWriteToLocation`), served by the
|
|
131
|
+
* local filesystem provider, on this node, with an absolute `basePath`, is
|
|
132
|
+
* judged. A root that does not exist yet is judged by its nearest existing
|
|
133
|
+
* ancestor: that is the directory the node would have to create it in.
|
|
134
|
+
*
|
|
135
|
+
* A probe that cannot answer (a stat that fails for a reason other than
|
|
136
|
+
* permission) is `unknown` and never a finding (D393): an unreadable fact is
|
|
137
|
+
* not an unwritable disk.
|
|
138
|
+
*/
|
|
139
|
+
/** The local filesystem storage provider's id (`FilesystemStorageProvider.providerId`). */
|
|
140
|
+
var FILESYSTEM_PROVIDER_ID = "filesystem-storage";
|
|
141
|
+
var UNWRITABLE_PREFIX = "liveness:storage-root-unwritable:";
|
|
142
|
+
/** The hub-local, writable filesystem roots among `locations`, deduplicated by path. */
|
|
143
|
+
function storageRootTargets(locations, localNodeId) {
|
|
144
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
145
|
+
for (const location of locations) {
|
|
146
|
+
if (location.providerId !== FILESYSTEM_PROVIDER_ID) continue;
|
|
147
|
+
if (location.nodeId !== void 0 && location.nodeId !== localNodeId) continue;
|
|
148
|
+
if (!require_dist.mayWriteToLocation(location)) continue;
|
|
149
|
+
const basePath = location.config["basePath"];
|
|
150
|
+
if (typeof basePath !== "string" || !node_path.default.isAbsolute(basePath)) continue;
|
|
151
|
+
const root = node_path.default.resolve(basePath);
|
|
152
|
+
byPath.set(root, [...byPath.get(root) ?? [], location.id]);
|
|
153
|
+
}
|
|
154
|
+
return [...byPath.entries()].map(([root, locationIds]) => ({
|
|
155
|
+
path: root,
|
|
156
|
+
locationIds: locationIds.toSorted()
|
|
157
|
+
})).toSorted((a, b) => a.path.localeCompare(b.path));
|
|
158
|
+
}
|
|
159
|
+
function isMissing(err) {
|
|
160
|
+
return err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "ENOTDIR");
|
|
161
|
+
}
|
|
162
|
+
/** Probe one root: the root itself, or the nearest ancestor that exists. */
|
|
163
|
+
async function probeStorageRoot(target, probe) {
|
|
164
|
+
let dir = target.path;
|
|
165
|
+
let stat;
|
|
166
|
+
for (;;) try {
|
|
167
|
+
stat = await probe.stat(dir);
|
|
168
|
+
break;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const parent = node_path.default.dirname(dir);
|
|
171
|
+
if (!isMissing(err) || parent === dir) return {
|
|
172
|
+
kind: "unknown",
|
|
173
|
+
target,
|
|
174
|
+
reason: `stat ${dir} failed: ${require_dist.errMsg(err)}`
|
|
175
|
+
};
|
|
176
|
+
dir = parent;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await probe.canWrite(dir);
|
|
180
|
+
return {
|
|
181
|
+
kind: "writable",
|
|
182
|
+
target
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "unwritable",
|
|
187
|
+
target,
|
|
188
|
+
blockedAt: dir,
|
|
189
|
+
stat,
|
|
190
|
+
reason: require_dist.errMsg(err)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function octalMode(mode) {
|
|
195
|
+
return (mode & 4095).toString(8).padStart(4, "0");
|
|
196
|
+
}
|
|
197
|
+
function processIdentity(probe) {
|
|
198
|
+
if (probe.processUid === null) return "this process";
|
|
199
|
+
return `uid ${String(probe.processUid)}` + (probe.processGid === null ? "" : ` (gid ${String(probe.processGid)})`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One finding per DIRECTORY that refused — several roots under one unwritable
|
|
203
|
+
* mount (`/recordings` holding recordings, event media and artifacts) are one
|
|
204
|
+
* cause and one alert. Stable id = that directory.
|
|
205
|
+
*/
|
|
206
|
+
function storageRootFindings(results, probe) {
|
|
207
|
+
const byBlocked = /* @__PURE__ */ new Map();
|
|
208
|
+
for (const result of results) {
|
|
209
|
+
if (result.kind !== "unwritable") continue;
|
|
210
|
+
const entry = byBlocked.get(result.blockedAt) ?? {
|
|
211
|
+
stat: result.stat,
|
|
212
|
+
roots: [],
|
|
213
|
+
locationIds: []
|
|
214
|
+
};
|
|
215
|
+
entry.roots.push(result.target.path);
|
|
216
|
+
entry.locationIds.push(...result.target.locationIds);
|
|
217
|
+
byBlocked.set(result.blockedAt, entry);
|
|
218
|
+
}
|
|
219
|
+
const uidFix = probe.processUid === null ? "<uid>" : String(probe.processUid);
|
|
220
|
+
const gidFix = probe.processGid === null ? "<gid>" : String(probe.processGid);
|
|
221
|
+
return [...byBlocked.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([blockedAt, { stat, roots, locationIds }]) => {
|
|
222
|
+
const missing = roots.filter((r) => r !== blockedAt);
|
|
223
|
+
const below = missing.length > 0 ? ` (${missing.join(", ")} must be created inside it)` : "";
|
|
224
|
+
return {
|
|
225
|
+
id: `${UNWRITABLE_PREFIX}${blockedAt}`,
|
|
226
|
+
severity: "error",
|
|
227
|
+
title: "Storage location not writable",
|
|
228
|
+
message: `${blockedAt} is owned by ${String(stat.uid)}:${String(stat.gid)} with mode ${octalMode(stat.mode)}, and CamStack runs as ${processIdentity(probe)} — nothing can be written there${below}. Locations affected: ${locationIds.toSorted().join(", ")}. Fix the ownership on the host, e.g. chown -R ${uidFix}:${gidFix} <the host directory bound at ${blockedAt}>.`
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function isStorageRootFindingId(id) {
|
|
233
|
+
return id.startsWith(UNWRITABLE_PREFIX);
|
|
234
|
+
}
|
|
235
|
+
/** The real probe: `fs.access(W_OK | X_OK)` as THIS process, and `fs.stat`. */
|
|
236
|
+
function nodeStorageRootProbe(fs) {
|
|
237
|
+
return {
|
|
238
|
+
canWrite: (dir) => fs.access(dir, node_fs.constants.W_OK | node_fs.constants.X_OK),
|
|
239
|
+
stat: async (dir) => {
|
|
240
|
+
const st = await fs.stat(dir);
|
|
241
|
+
return {
|
|
242
|
+
uid: st.uid,
|
|
243
|
+
gid: st.gid,
|
|
244
|
+
mode: st.mode
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
processUid: typeof process.getuid === "function" ? process.getuid() : null,
|
|
248
|
+
processGid: typeof process.getgid === "function" ? process.getgid() : null
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
55
252
|
//#region src/builtins/liveness-monitor/liveness-monitor.addon.ts
|
|
56
253
|
/**
|
|
57
254
|
* liveness-monitor — the node states, out loud, when it has stopped working.
|
|
@@ -74,11 +271,8 @@ function isLivenessFindingId(id) {
|
|
|
74
271
|
* cause the outage it was meant to report — this repo has already stopped a
|
|
75
272
|
* hub that way once (the remedial-action watcher, 2026-07-30).
|
|
76
273
|
*/
|
|
77
|
-
/**
|
|
78
|
-
var
|
|
79
|
-
type: "addon",
|
|
80
|
-
id: "liveness-monitor"
|
|
81
|
-
};
|
|
274
|
+
/** The node this monitor runs on (it is hub-only). */
|
|
275
|
+
var HUB_NODE_ID = "hub";
|
|
82
276
|
/** How often the invariants are checked. */
|
|
83
277
|
var TICK_INTERVAL_MS = 5 * 6e4;
|
|
84
278
|
/**
|
|
@@ -101,13 +295,19 @@ var RECORDING_LOOKBACK_MS = 1440 * 6e4;
|
|
|
101
295
|
var LivenessMonitorAddon = class extends require_dist.BaseAddon {
|
|
102
296
|
tickTimer = null;
|
|
103
297
|
startedAtMs = 0;
|
|
104
|
-
|
|
105
|
-
|
|
298
|
+
reporter = null;
|
|
299
|
+
/** Roots whose writability could not be read, already said once. */
|
|
300
|
+
unknownRootsSaid = /* @__PURE__ */ new Set();
|
|
106
301
|
constructor() {
|
|
107
302
|
super({});
|
|
108
303
|
}
|
|
109
304
|
async onInitialize() {
|
|
110
305
|
this.startedAtMs = Date.now();
|
|
306
|
+
this.reporter = new LivenessReporter({
|
|
307
|
+
logger: this.ctx.logger,
|
|
308
|
+
emit: (event) => this.ctx.eventBus.emit(event)
|
|
309
|
+
});
|
|
310
|
+
this.checkStorageRoots();
|
|
111
311
|
this.tickTimer = setInterval(() => {
|
|
112
312
|
this.tick();
|
|
113
313
|
}, TICK_INTERVAL_MS);
|
|
@@ -127,11 +327,12 @@ var LivenessMonitorAddon = class extends require_dist.BaseAddon {
|
|
|
127
327
|
* without waiting out the interval.
|
|
128
328
|
*/
|
|
129
329
|
async tick() {
|
|
130
|
-
|
|
330
|
+
const storage = await this.checkStorageRoots();
|
|
331
|
+
if (Date.now() - this.startedAtMs < BOOT_GRACE_MS) return storage;
|
|
131
332
|
try {
|
|
132
333
|
const findings = evaluateLiveness(await this.gather());
|
|
133
|
-
this.report(findings);
|
|
134
|
-
return findings;
|
|
334
|
+
this.reporter?.report(findings, isLivenessFindingId);
|
|
335
|
+
return [...storage, ...findings];
|
|
135
336
|
} catch (err) {
|
|
136
337
|
this.ctx.logger.error("liveness-monitor: check failed — node health is UNKNOWN this tick", { meta: { error: require_dist.errMsg(err) } });
|
|
137
338
|
return [];
|
|
@@ -206,33 +407,33 @@ var LivenessMonitorAddon = class extends require_dist.BaseAddon {
|
|
|
206
407
|
this.ctx.logger.warn(`liveness: recording availability was unreadable (${window}) — this camera is not judged this tick`, { tags: { deviceId } });
|
|
207
408
|
return null;
|
|
208
409
|
}
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
this.ctx.logger.
|
|
235
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Every storage root this node writes to must be writable BY THIS PROCESS.
|
|
412
|
+
* Reported through the same finding path (one ERROR, one alert, a recovery
|
|
413
|
+
* when it is fixed). Never throws: an unreadable list is said and skipped.
|
|
414
|
+
*/
|
|
415
|
+
async checkStorageRoots() {
|
|
416
|
+
const reporter = this.reporter;
|
|
417
|
+
if (reporter === null) return [];
|
|
418
|
+
try {
|
|
419
|
+
const locations = await this.ctx.api.storage.listLocations.query({});
|
|
420
|
+
const probe = nodeStorageRootProbe(node_fs_promises);
|
|
421
|
+
const results = await Promise.all(storageRootTargets(locations, HUB_NODE_ID).map((t) => probeStorageRoot(t, probe)));
|
|
422
|
+
for (const result of results) {
|
|
423
|
+
if (result.kind !== "unknown" || this.unknownRootsSaid.has(result.target.path)) continue;
|
|
424
|
+
this.unknownRootsSaid.add(result.target.path);
|
|
425
|
+
this.ctx.logger.warn("liveness: storage root writability could not be read — not judged", { meta: {
|
|
426
|
+
path: result.target.path,
|
|
427
|
+
locationIds: result.target.locationIds,
|
|
428
|
+
reason: result.reason
|
|
429
|
+
} });
|
|
430
|
+
}
|
|
431
|
+
const findings = storageRootFindings(results, probe);
|
|
432
|
+
reporter.report(findings, isStorageRootFindingId);
|
|
433
|
+
return findings;
|
|
434
|
+
} catch (err) {
|
|
435
|
+
this.ctx.logger.warn("liveness: storage locations unreadable — storage roots not checked", { meta: { error: require_dist.errMsg(err) } });
|
|
436
|
+
return [];
|
|
236
437
|
}
|
|
237
438
|
}
|
|
238
439
|
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Oa as EventCategory, Vn as mayWriteToLocation, Wi as createEvent, ei as errMsg, it as RecordingStatusSchema, nt as RECORDING_SOURCE_CAMSTACK_ADDON, ti as BaseAddon } from "../../dist-C9lbdBL5.mjs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { constants } from "node:fs";
|
|
2
5
|
//#region src/builtins/liveness-monitor/liveness-checks.ts
|
|
3
6
|
var NO_DEVICES = "liveness:no-devices";
|
|
4
7
|
var ALL_OFFLINE = "liveness:all-devices-offline";
|
|
@@ -50,6 +53,198 @@ function isLivenessFindingId(id) {
|
|
|
50
53
|
return id === NO_DEVICES || id === ALL_OFFLINE || id.startsWith(NO_FOOTAGE_PREFIX);
|
|
51
54
|
}
|
|
52
55
|
//#endregion
|
|
56
|
+
//#region src/builtins/liveness-monitor/liveness-reporter.ts
|
|
57
|
+
/**
|
|
58
|
+
* Announce new liveness faults and their recoveries — never an unchanged fault.
|
|
59
|
+
*
|
|
60
|
+
* One ERROR line and one `SystemLivenessFailed` event per NEW finding id (the
|
|
61
|
+
* alert centre turns it into one persistent alert), one INFO line and one
|
|
62
|
+
* `SystemLivenessRecovered` when it is gone. Several families of checks run on
|
|
63
|
+
* different cadences (the device checks wait out the boot grace, the storage
|
|
64
|
+
* check does not), so a recovery is judged only within the family that was
|
|
65
|
+
* just evaluated: a storage check that found nothing must not "recover" a
|
|
66
|
+
* device finding it never looked at.
|
|
67
|
+
*/
|
|
68
|
+
/** Event source stamped on every emission — the addon that observed it. */
|
|
69
|
+
var LIVENESS_SOURCE = {
|
|
70
|
+
type: "addon",
|
|
71
|
+
id: "liveness-monitor"
|
|
72
|
+
};
|
|
73
|
+
var LivenessReporter = class {
|
|
74
|
+
deps;
|
|
75
|
+
/** Finding ids currently reported, so recovery can be detected and announced. */
|
|
76
|
+
active = /* @__PURE__ */ new Set();
|
|
77
|
+
constructor(deps) {
|
|
78
|
+
this.deps = deps;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* @param findings what the family found this evaluation
|
|
82
|
+
* @param inFamily which ids the family owns — only those can recover here
|
|
83
|
+
*/
|
|
84
|
+
report(findings, inFamily) {
|
|
85
|
+
const current = new Set(findings.map((f) => f.id));
|
|
86
|
+
for (const finding of findings) {
|
|
87
|
+
if (this.active.has(finding.id)) continue;
|
|
88
|
+
this.active.add(finding.id);
|
|
89
|
+
this.deps.logger.error(`liveness: ${finding.title} — ${finding.message}`, {
|
|
90
|
+
...finding.deviceId !== void 0 ? { tags: { deviceId: finding.deviceId } } : {},
|
|
91
|
+
meta: {
|
|
92
|
+
findingId: finding.id,
|
|
93
|
+
severity: finding.severity
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
this.deps.emit(createEvent(EventCategory.SystemLivenessFailed, LIVENESS_SOURCE, {
|
|
97
|
+
findingId: finding.id,
|
|
98
|
+
severity: finding.severity,
|
|
99
|
+
title: finding.title,
|
|
100
|
+
message: finding.message,
|
|
101
|
+
...finding.deviceId !== void 0 ? { deviceId: finding.deviceId } : {},
|
|
102
|
+
...finding.deviceName !== void 0 ? { name: finding.deviceName } : {}
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
for (const id of [...this.active]) {
|
|
106
|
+
if (current.has(id) || !inFamily(id)) continue;
|
|
107
|
+
this.active.delete(id);
|
|
108
|
+
this.deps.logger.info(`liveness: recovered — ${id}`, { meta: { findingId: id } });
|
|
109
|
+
this.deps.emit(createEvent(EventCategory.SystemLivenessRecovered, LIVENESS_SOURCE, { findingId: id }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/builtins/liveness-monitor/storage-root-access.ts
|
|
115
|
+
/**
|
|
116
|
+
* Can this node WRITE where its storage locations say media goes?
|
|
117
|
+
*
|
|
118
|
+
* 2026-10-23, a fresh install of the dev hub: the bind mounted at
|
|
119
|
+
* `/recordings` was `root:root 0755` while the hub runs as uid 1001. Nothing
|
|
120
|
+
* said so at boot; the first sign was an `EACCES` on `/recordings/artifacts`,
|
|
121
|
+
* and recordings would have failed one write at a time. A root the process
|
|
122
|
+
* cannot write is a fact known the moment the node starts, so it is reported
|
|
123
|
+
* then — once, naming the path, its owner, its mode and the uid — instead of
|
|
124
|
+
* as a stream of per-write errors.
|
|
125
|
+
*
|
|
126
|
+
* Only a location this node may WRITE (`mayWriteToLocation`), served by the
|
|
127
|
+
* local filesystem provider, on this node, with an absolute `basePath`, is
|
|
128
|
+
* judged. A root that does not exist yet is judged by its nearest existing
|
|
129
|
+
* ancestor: that is the directory the node would have to create it in.
|
|
130
|
+
*
|
|
131
|
+
* A probe that cannot answer (a stat that fails for a reason other than
|
|
132
|
+
* permission) is `unknown` and never a finding (D393): an unreadable fact is
|
|
133
|
+
* not an unwritable disk.
|
|
134
|
+
*/
|
|
135
|
+
/** The local filesystem storage provider's id (`FilesystemStorageProvider.providerId`). */
|
|
136
|
+
var FILESYSTEM_PROVIDER_ID = "filesystem-storage";
|
|
137
|
+
var UNWRITABLE_PREFIX = "liveness:storage-root-unwritable:";
|
|
138
|
+
/** The hub-local, writable filesystem roots among `locations`, deduplicated by path. */
|
|
139
|
+
function storageRootTargets(locations, localNodeId) {
|
|
140
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
141
|
+
for (const location of locations) {
|
|
142
|
+
if (location.providerId !== FILESYSTEM_PROVIDER_ID) continue;
|
|
143
|
+
if (location.nodeId !== void 0 && location.nodeId !== localNodeId) continue;
|
|
144
|
+
if (!mayWriteToLocation(location)) continue;
|
|
145
|
+
const basePath = location.config["basePath"];
|
|
146
|
+
if (typeof basePath !== "string" || !path.isAbsolute(basePath)) continue;
|
|
147
|
+
const root = path.resolve(basePath);
|
|
148
|
+
byPath.set(root, [...byPath.get(root) ?? [], location.id]);
|
|
149
|
+
}
|
|
150
|
+
return [...byPath.entries()].map(([root, locationIds]) => ({
|
|
151
|
+
path: root,
|
|
152
|
+
locationIds: locationIds.toSorted()
|
|
153
|
+
})).toSorted((a, b) => a.path.localeCompare(b.path));
|
|
154
|
+
}
|
|
155
|
+
function isMissing(err) {
|
|
156
|
+
return err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "ENOTDIR");
|
|
157
|
+
}
|
|
158
|
+
/** Probe one root: the root itself, or the nearest ancestor that exists. */
|
|
159
|
+
async function probeStorageRoot(target, probe) {
|
|
160
|
+
let dir = target.path;
|
|
161
|
+
let stat;
|
|
162
|
+
for (;;) try {
|
|
163
|
+
stat = await probe.stat(dir);
|
|
164
|
+
break;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const parent = path.dirname(dir);
|
|
167
|
+
if (!isMissing(err) || parent === dir) return {
|
|
168
|
+
kind: "unknown",
|
|
169
|
+
target,
|
|
170
|
+
reason: `stat ${dir} failed: ${errMsg(err)}`
|
|
171
|
+
};
|
|
172
|
+
dir = parent;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await probe.canWrite(dir);
|
|
176
|
+
return {
|
|
177
|
+
kind: "writable",
|
|
178
|
+
target
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return {
|
|
182
|
+
kind: "unwritable",
|
|
183
|
+
target,
|
|
184
|
+
blockedAt: dir,
|
|
185
|
+
stat,
|
|
186
|
+
reason: errMsg(err)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function octalMode(mode) {
|
|
191
|
+
return (mode & 4095).toString(8).padStart(4, "0");
|
|
192
|
+
}
|
|
193
|
+
function processIdentity(probe) {
|
|
194
|
+
if (probe.processUid === null) return "this process";
|
|
195
|
+
return `uid ${String(probe.processUid)}` + (probe.processGid === null ? "" : ` (gid ${String(probe.processGid)})`);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One finding per DIRECTORY that refused — several roots under one unwritable
|
|
199
|
+
* mount (`/recordings` holding recordings, event media and artifacts) are one
|
|
200
|
+
* cause and one alert. Stable id = that directory.
|
|
201
|
+
*/
|
|
202
|
+
function storageRootFindings(results, probe) {
|
|
203
|
+
const byBlocked = /* @__PURE__ */ new Map();
|
|
204
|
+
for (const result of results) {
|
|
205
|
+
if (result.kind !== "unwritable") continue;
|
|
206
|
+
const entry = byBlocked.get(result.blockedAt) ?? {
|
|
207
|
+
stat: result.stat,
|
|
208
|
+
roots: [],
|
|
209
|
+
locationIds: []
|
|
210
|
+
};
|
|
211
|
+
entry.roots.push(result.target.path);
|
|
212
|
+
entry.locationIds.push(...result.target.locationIds);
|
|
213
|
+
byBlocked.set(result.blockedAt, entry);
|
|
214
|
+
}
|
|
215
|
+
const uidFix = probe.processUid === null ? "<uid>" : String(probe.processUid);
|
|
216
|
+
const gidFix = probe.processGid === null ? "<gid>" : String(probe.processGid);
|
|
217
|
+
return [...byBlocked.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([blockedAt, { stat, roots, locationIds }]) => {
|
|
218
|
+
const missing = roots.filter((r) => r !== blockedAt);
|
|
219
|
+
const below = missing.length > 0 ? ` (${missing.join(", ")} must be created inside it)` : "";
|
|
220
|
+
return {
|
|
221
|
+
id: `${UNWRITABLE_PREFIX}${blockedAt}`,
|
|
222
|
+
severity: "error",
|
|
223
|
+
title: "Storage location not writable",
|
|
224
|
+
message: `${blockedAt} is owned by ${String(stat.uid)}:${String(stat.gid)} with mode ${octalMode(stat.mode)}, and CamStack runs as ${processIdentity(probe)} — nothing can be written there${below}. Locations affected: ${locationIds.toSorted().join(", ")}. Fix the ownership on the host, e.g. chown -R ${uidFix}:${gidFix} <the host directory bound at ${blockedAt}>.`
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function isStorageRootFindingId(id) {
|
|
229
|
+
return id.startsWith(UNWRITABLE_PREFIX);
|
|
230
|
+
}
|
|
231
|
+
/** The real probe: `fs.access(W_OK | X_OK)` as THIS process, and `fs.stat`. */
|
|
232
|
+
function nodeStorageRootProbe(fs) {
|
|
233
|
+
return {
|
|
234
|
+
canWrite: (dir) => fs.access(dir, constants.W_OK | constants.X_OK),
|
|
235
|
+
stat: async (dir) => {
|
|
236
|
+
const st = await fs.stat(dir);
|
|
237
|
+
return {
|
|
238
|
+
uid: st.uid,
|
|
239
|
+
gid: st.gid,
|
|
240
|
+
mode: st.mode
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
processUid: typeof process.getuid === "function" ? process.getuid() : null,
|
|
244
|
+
processGid: typeof process.getgid === "function" ? process.getgid() : null
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
53
248
|
//#region src/builtins/liveness-monitor/liveness-monitor.addon.ts
|
|
54
249
|
/**
|
|
55
250
|
* liveness-monitor — the node states, out loud, when it has stopped working.
|
|
@@ -72,11 +267,8 @@ function isLivenessFindingId(id) {
|
|
|
72
267
|
* cause the outage it was meant to report — this repo has already stopped a
|
|
73
268
|
* hub that way once (the remedial-action watcher, 2026-07-30).
|
|
74
269
|
*/
|
|
75
|
-
/**
|
|
76
|
-
var
|
|
77
|
-
type: "addon",
|
|
78
|
-
id: "liveness-monitor"
|
|
79
|
-
};
|
|
270
|
+
/** The node this monitor runs on (it is hub-only). */
|
|
271
|
+
var HUB_NODE_ID = "hub";
|
|
80
272
|
/** How often the invariants are checked. */
|
|
81
273
|
var TICK_INTERVAL_MS = 5 * 6e4;
|
|
82
274
|
/**
|
|
@@ -99,13 +291,19 @@ var RECORDING_LOOKBACK_MS = 1440 * 6e4;
|
|
|
99
291
|
var LivenessMonitorAddon = class extends BaseAddon {
|
|
100
292
|
tickTimer = null;
|
|
101
293
|
startedAtMs = 0;
|
|
102
|
-
|
|
103
|
-
|
|
294
|
+
reporter = null;
|
|
295
|
+
/** Roots whose writability could not be read, already said once. */
|
|
296
|
+
unknownRootsSaid = /* @__PURE__ */ new Set();
|
|
104
297
|
constructor() {
|
|
105
298
|
super({});
|
|
106
299
|
}
|
|
107
300
|
async onInitialize() {
|
|
108
301
|
this.startedAtMs = Date.now();
|
|
302
|
+
this.reporter = new LivenessReporter({
|
|
303
|
+
logger: this.ctx.logger,
|
|
304
|
+
emit: (event) => this.ctx.eventBus.emit(event)
|
|
305
|
+
});
|
|
306
|
+
this.checkStorageRoots();
|
|
109
307
|
this.tickTimer = setInterval(() => {
|
|
110
308
|
this.tick();
|
|
111
309
|
}, TICK_INTERVAL_MS);
|
|
@@ -125,11 +323,12 @@ var LivenessMonitorAddon = class extends BaseAddon {
|
|
|
125
323
|
* without waiting out the interval.
|
|
126
324
|
*/
|
|
127
325
|
async tick() {
|
|
128
|
-
|
|
326
|
+
const storage = await this.checkStorageRoots();
|
|
327
|
+
if (Date.now() - this.startedAtMs < BOOT_GRACE_MS) return storage;
|
|
129
328
|
try {
|
|
130
329
|
const findings = evaluateLiveness(await this.gather());
|
|
131
|
-
this.report(findings);
|
|
132
|
-
return findings;
|
|
330
|
+
this.reporter?.report(findings, isLivenessFindingId);
|
|
331
|
+
return [...storage, ...findings];
|
|
133
332
|
} catch (err) {
|
|
134
333
|
this.ctx.logger.error("liveness-monitor: check failed — node health is UNKNOWN this tick", { meta: { error: errMsg(err) } });
|
|
135
334
|
return [];
|
|
@@ -204,33 +403,33 @@ var LivenessMonitorAddon = class extends BaseAddon {
|
|
|
204
403
|
this.ctx.logger.warn(`liveness: recording availability was unreadable (${window}) — this camera is not judged this tick`, { tags: { deviceId } });
|
|
205
404
|
return null;
|
|
206
405
|
}
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
this.ctx.logger.
|
|
233
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Every storage root this node writes to must be writable BY THIS PROCESS.
|
|
408
|
+
* Reported through the same finding path (one ERROR, one alert, a recovery
|
|
409
|
+
* when it is fixed). Never throws: an unreadable list is said and skipped.
|
|
410
|
+
*/
|
|
411
|
+
async checkStorageRoots() {
|
|
412
|
+
const reporter = this.reporter;
|
|
413
|
+
if (reporter === null) return [];
|
|
414
|
+
try {
|
|
415
|
+
const locations = await this.ctx.api.storage.listLocations.query({});
|
|
416
|
+
const probe = nodeStorageRootProbe(fsp);
|
|
417
|
+
const results = await Promise.all(storageRootTargets(locations, HUB_NODE_ID).map((t) => probeStorageRoot(t, probe)));
|
|
418
|
+
for (const result of results) {
|
|
419
|
+
if (result.kind !== "unknown" || this.unknownRootsSaid.has(result.target.path)) continue;
|
|
420
|
+
this.unknownRootsSaid.add(result.target.path);
|
|
421
|
+
this.ctx.logger.warn("liveness: storage root writability could not be read — not judged", { meta: {
|
|
422
|
+
path: result.target.path,
|
|
423
|
+
locationIds: result.target.locationIds,
|
|
424
|
+
reason: result.reason
|
|
425
|
+
} });
|
|
426
|
+
}
|
|
427
|
+
const findings = storageRootFindings(results, probe);
|
|
428
|
+
reporter.report(findings, isStorageRootFindingId);
|
|
429
|
+
return findings;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.ctx.logger.warn("liveness: storage locations unreadable — storage roots not checked", { meta: { error: errMsg(err) } });
|
|
432
|
+
return [];
|
|
234
433
|
}
|
|
235
434
|
}
|
|
236
435
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announce new liveness faults and their recoveries — never an unchanged fault.
|
|
3
|
+
*
|
|
4
|
+
* One ERROR line and one `SystemLivenessFailed` event per NEW finding id (the
|
|
5
|
+
* alert centre turns it into one persistent alert), one INFO line and one
|
|
6
|
+
* `SystemLivenessRecovered` when it is gone. Several families of checks run on
|
|
7
|
+
* different cadences (the device checks wait out the boot grace, the storage
|
|
8
|
+
* check does not), so a recovery is judged only within the family that was
|
|
9
|
+
* just evaluated: a storage check that found nothing must not "recover" a
|
|
10
|
+
* device finding it never looked at.
|
|
11
|
+
*/
|
|
12
|
+
import { type IScopedLogger, type SystemEvent } from "@camstack/types";
|
|
13
|
+
import type { LivenessFinding } from "./liveness-checks.js";
|
|
14
|
+
export interface LivenessReporterDeps {
|
|
15
|
+
readonly logger: IScopedLogger;
|
|
16
|
+
readonly emit: (event: SystemEvent) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare class LivenessReporter {
|
|
19
|
+
private readonly deps;
|
|
20
|
+
/** Finding ids currently reported, so recovery can be detected and announced. */
|
|
21
|
+
private readonly active;
|
|
22
|
+
constructor(deps: LivenessReporterDeps);
|
|
23
|
+
/**
|
|
24
|
+
* @param findings what the family found this evaluation
|
|
25
|
+
* @param inFamily which ids the family owns — only those can recover here
|
|
26
|
+
*/
|
|
27
|
+
report(findings: readonly LivenessFinding[], inFamily: (id: string) => boolean): void;
|
|
28
|
+
}
|