@camstack/system 1.2.328 → 1.2.329
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/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- 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.d.ts +7 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +294 -79
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +294 -79
- 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-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- 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 +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/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 +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +4 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/sqlite-storage/sqlite-snapshot-protocol.d.ts +49 -0
- package/dist/builtins/sqlite-storage/sqlite-snapshot-worker.d.ts +33 -0
- package/dist/builtins/sqlite-storage/sqlite-snapshotter.d.ts +98 -0
- 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-backup/system-backup.service.d.ts +74 -20
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{composition-sources-GkgXF6B9.js → composition-sources-B7hfTiPS.js} +1 -1
- package/dist/{composition-sources-I8Rx7woY.mjs → composition-sources-BHPqf6So.mjs} +1 -1
- package/dist/{dist-BUejKKBm.js → dist-CSs0cFDN.js} +496 -337
- package/dist/{dist-DWq17vC8.mjs → dist-CV7IqaYq.mjs} +467 -338
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-DBPEzGop.js → retired-settings-keys-CGQnAoRx.js} +1 -1
- package/dist/{retired-settings-keys-BfHclEyd.mjs → retired-settings-keys-DSUs7U6l.mjs} +1 -1
- package/dist/sqlite-snapshot-protocol-2QaTouWV.mjs +34 -0
- package/dist/sqlite-snapshot-protocol-B9OndqZv.js +51 -0
- package/dist/sqlite-snapshot-worker.js +185 -0
- package/dist/sqlite-snapshot-worker.mjs +179 -0
- package/package.json +5 -2
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CSs0cFDN.js");
|
|
7
7
|
const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
|
|
8
8
|
let node_crypto = require("node:crypto");
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Sr as EventCategory, V as addonPagesCapability, Xn as errMsg, Zn as BaseAddon } from "../../dist-CV7IqaYq.mjs";
|
|
2
2
|
import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import * as path$1 from "node:path";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CSs0cFDN.js");
|
|
7
7
|
const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
|
|
8
8
|
let node_crypto = require("node:crypto");
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Sr as EventCategory, U as addonWidgetsCapability, Xn as errMsg, Zn as BaseAddon } from "../../dist-CV7IqaYq.mjs";
|
|
2
2
|
import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import * as path$1 from "node:path";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CSs0cFDN.js");
|
|
7
7
|
//#region src/builtins/alerts/prune-policy.ts
|
|
8
8
|
/**
|
|
9
9
|
* Ids older than `cutoffMs`, oldest first.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Sr as EventCategory, W as alertsCapability, Xn as errMsg, Zn as BaseAddon, n as AlertSchema, sr as createEvent } from "../../dist-CV7IqaYq.mjs";
|
|
2
2
|
//#region src/builtins/alerts/prune-policy.ts
|
|
3
3
|
/**
|
|
4
4
|
* Ids older than `cutoffMs`, oldest first.
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CSs0cFDN.js");
|
|
7
7
|
//#region src/builtins/autotrack/autotrack-config.ts
|
|
8
8
|
var DEFAULT_AUTOTRACK_LOOP_CONFIG = {
|
|
9
9
|
deadZoneFraction: .5,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Sr as EventCategory, Xn as errMsg, Zn as BaseAddon, b as FIRST_LEVEL_MACRO_CLASSES, pr as isEvent, q as bareAddonId, sn as ptzAutotrackCapability } from "../../dist-CV7IqaYq.mjs";
|
|
2
2
|
//#region src/builtins/autotrack/autotrack-config.ts
|
|
3
3
|
var DEFAULT_AUTOTRACK_LOOP_CONFIG = {
|
|
4
4
|
deadZoneFraction: .5,
|
|
@@ -134,6 +134,13 @@ export declare class BackupOrchestratorAddon extends BaseAddon<Record<string, ne
|
|
|
134
134
|
* `cleanStagingArea`.
|
|
135
135
|
*/
|
|
136
136
|
private runBackupBuild;
|
|
137
|
+
/**
|
|
138
|
+
* THIS node's database owner (D693), reached through `database-snapshot`
|
|
139
|
+
* pinned to the local node: the paths it answers must be on this host,
|
|
140
|
+
* because this process archives them. No local node id means no pin, and an
|
|
141
|
+
* unpinned singleton call could be answered by another host — refuse.
|
|
142
|
+
*/
|
|
143
|
+
private databaseOwner;
|
|
137
144
|
/**
|
|
138
145
|
* Push the staged archive to a single destination via chunked
|
|
139
146
|
* upload, then update the per-location manifest. Applies the
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CSs0cFDN.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
let buffer = require("buffer");
|
|
9
9
|
let node_fs_promises = require("node:fs/promises");
|
|
@@ -2874,8 +2874,7 @@ var Tr = Symbol("onEntry"), gs = Symbol("checkFs"), xr = Symbol("checkFs2"), bs
|
|
|
2874
2874
|
return this[O$1](o, t);
|
|
2875
2875
|
}
|
|
2876
2876
|
}
|
|
2877
|
-
}
|
|
2878
|
-
var co = (s) => {
|
|
2877
|
+
}, co = (s) => {
|
|
2879
2878
|
let t = new Te(s), e = s.file, i = node_fs$1.default.statSync(e);
|
|
2880
2879
|
new Me(e, {
|
|
2881
2880
|
readSize: s.maxReadSize || 16 * 1024 * 1024,
|
|
@@ -2895,7 +2894,8 @@ var co = (s) => {
|
|
|
2895
2894
|
}
|
|
2896
2895
|
});
|
|
2897
2896
|
});
|
|
2898
|
-
}
|
|
2897
|
+
};
|
|
2898
|
+
K(co, fo, (s) => new Te(s), (s) => new Xt(s), (s, t) => {
|
|
2899
2899
|
t?.length && Xi(s, t);
|
|
2900
2900
|
});
|
|
2901
2901
|
var mo = (s, t) => {
|
|
@@ -3016,6 +3016,44 @@ var BackupCancelledError = class extends Error {
|
|
|
3016
3016
|
this.name = "BackupCancelledError";
|
|
3017
3017
|
}
|
|
3018
3018
|
};
|
|
3019
|
+
/**
|
|
3020
|
+
* Thrown when an archive would carry a live database and its owner could not
|
|
3021
|
+
* produce a verified snapshot of it (D693). Nothing was built: a run that
|
|
3022
|
+
* "succeeded" without the database — or with a torn copy of it — would push
|
|
3023
|
+
* the oldest good archive out of every destination's retention.
|
|
3024
|
+
*/
|
|
3025
|
+
var BackupSnapshotRefusedError = class extends Error {
|
|
3026
|
+
constructor(reason) {
|
|
3027
|
+
super(`settings database snapshot failed — no archive built, nothing uploaded, retention untouched: ${reason}`);
|
|
3028
|
+
this.name = "BackupSnapshotRefusedError";
|
|
3029
|
+
}
|
|
3030
|
+
};
|
|
3031
|
+
/**
|
|
3032
|
+
* Settle with `pending`, or reject with {@link BackupCancelledError} as soon as
|
|
3033
|
+
* `signal` fires. A pending call abandoned by the cancel still settles later:
|
|
3034
|
+
* `onAbandoned` runs then, to clean up what it produced.
|
|
3035
|
+
*/
|
|
3036
|
+
function raceAbort(pending, signal, onAbandoned) {
|
|
3037
|
+
if (signal === void 0) return pending;
|
|
3038
|
+
return new Promise((resolve, reject) => {
|
|
3039
|
+
let abandoned = false;
|
|
3040
|
+
const onAbort = () => {
|
|
3041
|
+
abandoned = true;
|
|
3042
|
+
reject(new BackupCancelledError());
|
|
3043
|
+
};
|
|
3044
|
+
if (signal.aborted) onAbort();
|
|
3045
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
3046
|
+
pending.then((value) => {
|
|
3047
|
+
signal.removeEventListener("abort", onAbort);
|
|
3048
|
+
if (abandoned) onAbandoned();
|
|
3049
|
+
else resolve(value);
|
|
3050
|
+
}, (err) => {
|
|
3051
|
+
signal.removeEventListener("abort", onAbort);
|
|
3052
|
+
if (abandoned) onAbandoned();
|
|
3053
|
+
else reject(err);
|
|
3054
|
+
});
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3019
3057
|
/** Throw `BackupCancelledError` when the signal has fired. */
|
|
3020
3058
|
function throwIfCancelled(signal) {
|
|
3021
3059
|
if (signal?.aborted) throw new BackupCancelledError();
|
|
@@ -3068,11 +3106,48 @@ var EXCLUDED_DIR_NAME = "node_modules";
|
|
|
3068
3106
|
* trailing `/`) and backslash separators.
|
|
3069
3107
|
*/
|
|
3070
3108
|
function isExcludedFromBackup(relPath) {
|
|
3071
|
-
const norm = relPath
|
|
3109
|
+
const norm = normalizeArchivePath(relPath);
|
|
3072
3110
|
if (norm.length === 0) return false;
|
|
3073
|
-
|
|
3111
|
+
const segments = norm.split("/");
|
|
3112
|
+
if (segments.includes(EXCLUDED_DIR_NAME)) return true;
|
|
3113
|
+
if (segments.some((seg) => seg.endsWith(".backup-snapshot-partial"))) return true;
|
|
3114
|
+
if (norm.endsWith(".backup-snapshot")) return true;
|
|
3074
3115
|
return DEFAULT_BACKUP_EXCLUDES.some((prefix) => norm === prefix || norm.startsWith(`${prefix}/`));
|
|
3075
3116
|
}
|
|
3117
|
+
/** `./a/b/` and `a\\b` → `a/b`: the one spelling every archive-path comparison uses. */
|
|
3118
|
+
function normalizeArchivePath(relPath) {
|
|
3119
|
+
return relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3120
|
+
}
|
|
3121
|
+
/** The files SQLite keeps beside a database. A live one is never archived. */
|
|
3122
|
+
var SQLITE_SIDECAR_SUFFIXES = [
|
|
3123
|
+
"-wal",
|
|
3124
|
+
"-shm",
|
|
3125
|
+
"-journal"
|
|
3126
|
+
];
|
|
3127
|
+
var SQLITE_DATABASE_SUFFIXES = [
|
|
3128
|
+
".db",
|
|
3129
|
+
".sqlite",
|
|
3130
|
+
".sqlite3"
|
|
3131
|
+
];
|
|
3132
|
+
/**
|
|
3133
|
+
* The database a `-wal` / `-shm` / `-journal` belongs to, or null for any other
|
|
3134
|
+
* path. The base must name a database (`.db` / `.sqlite` / `.sqlite3`): a
|
|
3135
|
+
* `build-journal` is not a sidecar, and must not stop every backup as a "live
|
|
3136
|
+
* database" that does not exist.
|
|
3137
|
+
*/
|
|
3138
|
+
function databaseOfSidecar(relPath) {
|
|
3139
|
+
for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
|
|
3140
|
+
if (!relPath.endsWith(suffix)) continue;
|
|
3141
|
+
const db = relPath.slice(0, -suffix.length);
|
|
3142
|
+
return SQLITE_DATABASE_SUFFIXES.some((dbSuffix) => db.endsWith(dbSuffix)) ? db : null;
|
|
3143
|
+
}
|
|
3144
|
+
return null;
|
|
3145
|
+
}
|
|
3146
|
+
/** A SQLite database file, or a sidecar of one. */
|
|
3147
|
+
function isSqlitePath(relPath) {
|
|
3148
|
+
const db = databaseOfSidecar(relPath) ?? relPath;
|
|
3149
|
+
return SQLITE_DATABASE_SUFFIXES.some((suffix) => db.endsWith(suffix));
|
|
3150
|
+
}
|
|
3076
3151
|
/** Path of the manifest file embedded inside every archive. */
|
|
3077
3152
|
var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
|
|
3078
3153
|
var DEFAULT_STAT_CACHE_TTL_MS = 6e4;
|
|
@@ -3168,28 +3243,75 @@ var SystemBackupService = class {
|
|
|
3168
3243
|
}
|
|
3169
3244
|
if (present.length === 0) throw new Error(`createArchive: no locations from {${requested.join(", ")}} exist under ${this.dataDir}`);
|
|
3170
3245
|
node_fs.mkdirSync(node_path.dirname(opts.archivePath), { recursive: true });
|
|
3171
|
-
const
|
|
3246
|
+
const walked = [];
|
|
3172
3247
|
for (const loc of present) {
|
|
3173
3248
|
throwIfCancelled(opts.signal);
|
|
3174
3249
|
const abs = node_path.join(this.dataDir, loc);
|
|
3175
3250
|
const stat = await node_fs_promises.stat(abs);
|
|
3176
|
-
if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal))
|
|
3177
|
-
else
|
|
3251
|
+
if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) walked.push(e);
|
|
3252
|
+
else if (!isExcludedFromBackup(node_path.relative(this.dataDir, abs))) walked.push(toArchiveEntry(abs, this.dataDir, stat));
|
|
3253
|
+
}
|
|
3254
|
+
const walkedPaths = new Set(walked.map((e) => e.path));
|
|
3255
|
+
const owned = walked.some((e) => isSqlitePath(e.path)) ? await this.ownedDatabasesInScope(opts.owner, walkedPaths) : /* @__PURE__ */ new Set();
|
|
3256
|
+
const unowned = /* @__PURE__ */ new Set();
|
|
3257
|
+
for (const e of walked) {
|
|
3258
|
+
const db = databaseOfSidecar(e.path);
|
|
3259
|
+
if (db !== null && !owned.has(db)) unowned.add(db);
|
|
3260
|
+
}
|
|
3261
|
+
if (unowned.size > 0) {
|
|
3262
|
+
const databases = [...unowned].sort();
|
|
3263
|
+
this.logger.error("createArchive: a live SQLite database has no owner snapshot — no archive built", { meta: {
|
|
3264
|
+
databases,
|
|
3265
|
+
archivePath: opts.archivePath
|
|
3266
|
+
} });
|
|
3267
|
+
throw new BackupSnapshotRefusedError(`live SQLite database ${databases.join(", ")} has no owner that can snapshot it — a copy of its files would be torn; remove its stale -wal/-shm/-journal if nothing has it open, or leave its location out of the backup`);
|
|
3178
3268
|
}
|
|
3179
|
-
const
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3269
|
+
const snapshot = owned.size > 0 && opts.owner ? await this.takeOwnerSnapshot(opts.owner, owned, opts.signal) : null;
|
|
3270
|
+
try {
|
|
3271
|
+
const liveFiles = /* @__PURE__ */ new Set();
|
|
3272
|
+
for (const db of owned) {
|
|
3273
|
+
liveFiles.add(db);
|
|
3274
|
+
for (const suffix of SQLITE_SIDECAR_SUFFIXES) liveFiles.add(db + suffix);
|
|
3275
|
+
}
|
|
3276
|
+
const entries = walked.filter((e) => !liveFiles.has(e.path));
|
|
3277
|
+
if (snapshot !== null) entries.push(snapshot.entry);
|
|
3278
|
+
const manifest = {
|
|
3279
|
+
archiveVersion: 1,
|
|
3280
|
+
createdAt: Date.now(),
|
|
3281
|
+
dataDir: this.dataDir,
|
|
3282
|
+
locations: present,
|
|
3283
|
+
entries,
|
|
3284
|
+
totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
|
|
3285
|
+
totalFiles: entries.filter((e) => e.kind === "file").length,
|
|
3286
|
+
...snapshot !== null ? { sqliteSnapshots: [snapshot.archived] } : {}
|
|
3287
|
+
};
|
|
3288
|
+
const carriedSnapshot = snapshot?.archived.snapshotPath ?? null;
|
|
3289
|
+
return await this.writeArchive({
|
|
3290
|
+
archivePath: opts.archivePath,
|
|
3291
|
+
present,
|
|
3292
|
+
manifest,
|
|
3293
|
+
excluded: (rel) => {
|
|
3294
|
+
if (rel === carriedSnapshot) return false;
|
|
3295
|
+
return liveFiles.has(rel) || isExcludedFromBackup(rel);
|
|
3296
|
+
},
|
|
3297
|
+
...opts.signal ? { signal: opts.signal } : {},
|
|
3298
|
+
...opts.onArchiveBytes ? { onArchiveBytes: opts.onArchiveBytes } : {}
|
|
3299
|
+
});
|
|
3300
|
+
} finally {
|
|
3301
|
+
if (snapshot !== null) await this.removeSnapshot(snapshot.absPath);
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Tar `present` (filtered by `excluded`) plus the staged manifest into
|
|
3306
|
+
* `archivePath`.
|
|
3307
|
+
*/
|
|
3308
|
+
async writeArchive(opts) {
|
|
3309
|
+
const { present, manifest } = opts;
|
|
3188
3310
|
const manifestStagePath = `${opts.archivePath}.${process.pid}.manifest.json`;
|
|
3189
3311
|
node_fs.writeFileSync(manifestStagePath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
3190
3312
|
this.logger.info("createArchive: tarring", { meta: {
|
|
3191
3313
|
archivePath: opts.archivePath,
|
|
3192
|
-
locations: present,
|
|
3314
|
+
locations: [...present],
|
|
3193
3315
|
totalFiles: manifest.totalFiles,
|
|
3194
3316
|
totalBytes: manifest.totalBytes
|
|
3195
3317
|
} });
|
|
@@ -3205,8 +3327,8 @@ var SystemBackupService = class {
|
|
|
3205
3327
|
follow: true,
|
|
3206
3328
|
portable: true,
|
|
3207
3329
|
filter: (entryPath) => {
|
|
3208
|
-
const norm = entryPath
|
|
3209
|
-
if (
|
|
3330
|
+
const norm = normalizeArchivePath(entryPath);
|
|
3331
|
+
if (opts.excluded(norm)) return false;
|
|
3210
3332
|
if (!excludePrefix) return true;
|
|
3211
3333
|
return norm !== excludePrefix && !norm.startsWith(`${excludePrefix}/`);
|
|
3212
3334
|
}
|
|
@@ -3234,7 +3356,7 @@ var SystemBackupService = class {
|
|
|
3234
3356
|
const sizeBytes = node_fs.statSync(opts.archivePath).size;
|
|
3235
3357
|
return {
|
|
3236
3358
|
archivePath: opts.archivePath,
|
|
3237
|
-
includedLocations: present,
|
|
3359
|
+
includedLocations: [...present],
|
|
3238
3360
|
sizeBytes,
|
|
3239
3361
|
manifest
|
|
3240
3362
|
};
|
|
@@ -3287,41 +3409,11 @@ var SystemBackupService = class {
|
|
|
3287
3409
|
});
|
|
3288
3410
|
}
|
|
3289
3411
|
/**
|
|
3290
|
-
* Extract a previously-created archive over `targetDir`. Used at boot
|
|
3291
|
-
* by the restore hook — never call from a running server (would
|
|
3292
|
-
* corrupt sqlite mid-run).
|
|
3293
|
-
*
|
|
3294
|
-
* `locations` is an optional whitelist: only entries whose path
|
|
3295
|
-
* starts with one of the locations get extracted. Used by the
|
|
3296
|
-
* partial-restore flow where the operator picks a subset of the
|
|
3297
|
-
* archive contents to apply (e.g. "restore my devices but keep
|
|
3298
|
-
* my current TLS certs").
|
|
3299
|
-
*/
|
|
3300
|
-
async extractArchive(opts) {
|
|
3301
|
-
if (!node_fs.existsSync(opts.archivePath)) throw new Error(`extractArchive: ${opts.archivePath} does not exist`);
|
|
3302
|
-
node_fs.mkdirSync(opts.targetDir, { recursive: true });
|
|
3303
|
-
this.logger.info("extractArchive: untarring", { meta: {
|
|
3304
|
-
archivePath: opts.archivePath,
|
|
3305
|
-
targetDir: opts.targetDir,
|
|
3306
|
-
locations: opts.locations
|
|
3307
|
-
} });
|
|
3308
|
-
const allowedPrefixes = opts.locations ? opts.locations.map(normalizeLocation) : null;
|
|
3309
|
-
await uo({
|
|
3310
|
-
file: opts.archivePath,
|
|
3311
|
-
cwd: opts.targetDir,
|
|
3312
|
-
filter: (p) => {
|
|
3313
|
-
if (p === ".camstack-backup-manifest.json" || p === `./.camstack-backup-manifest.json`) return false;
|
|
3314
|
-
if (!allowedPrefixes) return true;
|
|
3315
|
-
const normalized = p.replace(/^\.\//, "");
|
|
3316
|
-
return allowedPrefixes.some((prefix) => entryMatchesLocation(normalized, prefix));
|
|
3317
|
-
}
|
|
3318
|
-
});
|
|
3319
|
-
}
|
|
3320
|
-
/**
|
|
3321
3412
|
* Schedule a restore for the next boot by writing a marker file.
|
|
3322
|
-
* `locations` (optional) carries through to the boot-time
|
|
3323
|
-
*
|
|
3324
|
-
* choice survives the process
|
|
3413
|
+
* `locations` (optional) carries through to the launcher's boot-time
|
|
3414
|
+
* restore (`server/backend/src/launcher.ts`, the only restore there is)
|
|
3415
|
+
* so the operator's "restore only X and Y" choice survives the process
|
|
3416
|
+
* restart.
|
|
3325
3417
|
*/
|
|
3326
3418
|
scheduleRestoreMarker(opts) {
|
|
3327
3419
|
if (!node_fs.existsSync(opts.archivePath)) throw new Error(`scheduleRestoreMarker: ${opts.archivePath} missing`);
|
|
@@ -3354,6 +3446,117 @@ var SystemBackupService = class {
|
|
|
3354
3446
|
const markerPath = this.getRestoreMarkerPath();
|
|
3355
3447
|
if (node_fs.existsSync(markerPath)) node_fs.rmSync(markerPath, { force: true });
|
|
3356
3448
|
}
|
|
3449
|
+
/**
|
|
3450
|
+
* The databases the owner declares, archive-relative, that this walk found.
|
|
3451
|
+
* One outside this data root is not in scope. An owner that cannot answer
|
|
3452
|
+
* stops the archive: without the answer, nothing says which file is live.
|
|
3453
|
+
*/
|
|
3454
|
+
async ownedDatabasesInScope(owner, walked) {
|
|
3455
|
+
if (owner === void 0) return /* @__PURE__ */ new Set();
|
|
3456
|
+
let declared;
|
|
3457
|
+
try {
|
|
3458
|
+
declared = await owner.listDatabases();
|
|
3459
|
+
} catch (err) {
|
|
3460
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3461
|
+
this.logger.error("createArchive: the database owner did not say what it owns — no archive built", { meta: { error: reason } });
|
|
3462
|
+
throw new BackupSnapshotRefusedError(`the database owner could not be asked: ${reason}`);
|
|
3463
|
+
}
|
|
3464
|
+
const inScope = /* @__PURE__ */ new Set();
|
|
3465
|
+
for (const abs of declared) {
|
|
3466
|
+
const rel = this.archiveRelative(abs);
|
|
3467
|
+
if (rel !== null && walked.has(rel)) inScope.add(rel);
|
|
3468
|
+
}
|
|
3469
|
+
return inScope;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Ask the owner for its snapshot, and place it in this archive. The snapshot
|
|
3473
|
+
* must sit on THIS data root — the call is pinned to this node, so a path
|
|
3474
|
+
* elsewhere means it reached another host, and that file is not ours to
|
|
3475
|
+
* read — beside an owned database in scope, and it must cover EVERY owned
|
|
3476
|
+
* database in scope. Anything else is refused.
|
|
3477
|
+
*
|
|
3478
|
+
* Only ever DELETED here: `<owned db>.backup-snapshot` under this data root,
|
|
3479
|
+
* a name this process derives itself. A path the owner ANSWERS is never
|
|
3480
|
+
* deleted on its word — answering the live database as the snapshot would
|
|
3481
|
+
* otherwise delete the live database. A file at any other path is left to
|
|
3482
|
+
* the owner's own sweep.
|
|
3483
|
+
*
|
|
3484
|
+
* A cancel is honoured while the owner is still copying; the copy it may
|
|
3485
|
+
* finish afterwards is removed when that call settles.
|
|
3486
|
+
*/
|
|
3487
|
+
async takeOwnerSnapshot(owner, owned, signal) {
|
|
3488
|
+
const removeOwnedSnapshots = async () => {
|
|
3489
|
+
for (const db of owned) await this.removeSnapshot(node_path.join(this.dataDir, db + require_dist.DATABASE_SNAPSHOT_SUFFIX));
|
|
3490
|
+
};
|
|
3491
|
+
let result;
|
|
3492
|
+
try {
|
|
3493
|
+
result = await raceAbort(owner.writeSnapshot(), signal, () => {
|
|
3494
|
+
this.logger.info("createArchive: run cancelled while the owner copied — its copy is removed when it lands", { meta: { databases: [...owned] } });
|
|
3495
|
+
return removeOwnedSnapshots();
|
|
3496
|
+
});
|
|
3497
|
+
} catch (err) {
|
|
3498
|
+
if (err instanceof BackupCancelledError) throw err;
|
|
3499
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3500
|
+
this.logger.error("createArchive: the owner could not snapshot its live database — no archive built", { meta: {
|
|
3501
|
+
databases: [...owned],
|
|
3502
|
+
error: reason
|
|
3503
|
+
} });
|
|
3504
|
+
await removeOwnedSnapshots();
|
|
3505
|
+
throw new BackupSnapshotRefusedError(reason);
|
|
3506
|
+
}
|
|
3507
|
+
try {
|
|
3508
|
+
const livePath = this.archiveRelative(result.livePath);
|
|
3509
|
+
const snapshotPath = this.archiveRelative(result.snapshotPath);
|
|
3510
|
+
if (livePath === null || snapshotPath === null) {
|
|
3511
|
+
this.logger.warn("createArchive: the owner answered a snapshot outside this data root — not touched, left to its sweep", { meta: {
|
|
3512
|
+
snapshot: result.snapshotPath,
|
|
3513
|
+
dataDir: this.dataDir
|
|
3514
|
+
} });
|
|
3515
|
+
throw new BackupSnapshotRefusedError(`snapshot ${result.snapshotPath} is not under this archive's data root ${this.dataDir}`);
|
|
3516
|
+
}
|
|
3517
|
+
if (snapshotPath !== livePath + ".backup-snapshot") throw new BackupSnapshotRefusedError(`snapshot ${snapshotPath} does not sit beside the database ${livePath} it copies`);
|
|
3518
|
+
const uncovered = [...owned].filter((db) => db !== livePath);
|
|
3519
|
+
if (!owned.has(livePath) || uncovered.length > 0) throw new BackupSnapshotRefusedError(`snapshot copies ${livePath}, but the owned database(s) in this archive are ${[...owned].join(", ")}`);
|
|
3520
|
+
const absPath = node_path.join(this.dataDir, snapshotPath);
|
|
3521
|
+
const stat = await node_fs_promises.stat(absPath);
|
|
3522
|
+
this.logger.info("createArchive: carrying the owner snapshot in place of the live database", { meta: {
|
|
3523
|
+
database: livePath,
|
|
3524
|
+
snapshot: snapshotPath,
|
|
3525
|
+
sizeBytes: stat.size,
|
|
3526
|
+
tables: result.tables.length,
|
|
3527
|
+
copyMs: result.copyMs,
|
|
3528
|
+
verifyMs: result.verifyMs
|
|
3529
|
+
} });
|
|
3530
|
+
return {
|
|
3531
|
+
absPath,
|
|
3532
|
+
archived: {
|
|
3533
|
+
...result,
|
|
3534
|
+
livePath,
|
|
3535
|
+
snapshotPath
|
|
3536
|
+
},
|
|
3537
|
+
entry: toArchiveEntry(absPath, this.dataDir, stat)
|
|
3538
|
+
};
|
|
3539
|
+
} catch (err) {
|
|
3540
|
+
await removeOwnedSnapshots();
|
|
3541
|
+
throw err;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
async removeSnapshot(absPath) {
|
|
3545
|
+
try {
|
|
3546
|
+
await node_fs_promises.rm(absPath, { force: true });
|
|
3547
|
+
} catch (err) {
|
|
3548
|
+
this.logger.warn("createArchive: owner snapshot could not be removed", { meta: {
|
|
3549
|
+
snapshot: absPath,
|
|
3550
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3551
|
+
} });
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
/** `abs` relative to the data root, or null when it lies outside it. */
|
|
3555
|
+
archiveRelative(abs) {
|
|
3556
|
+
const rel = node_path.relative(node_path.resolve(this.dataDir), node_path.resolve(abs));
|
|
3557
|
+
if (rel.length === 0 || rel.startsWith("..") || node_path.isAbsolute(rel)) return null;
|
|
3558
|
+
return normalizeArchivePath(rel);
|
|
3559
|
+
}
|
|
3357
3560
|
getRestoreMarkerPath() {
|
|
3358
3561
|
return node_path.join(this.dataDir, ".pending-restore.json");
|
|
3359
3562
|
}
|
|
@@ -3420,23 +3623,6 @@ function toArchiveEntry(abs, dataDir, stat) {
|
|
|
3420
3623
|
mtime: stat.mtimeMs
|
|
3421
3624
|
};
|
|
3422
3625
|
}
|
|
3423
|
-
/**
|
|
3424
|
-
* Strip leading `./` and trailing `/` so location prefix matching is
|
|
3425
|
-
* order-of-comparison invariant. Same shape on both sides of the
|
|
3426
|
-
* `entry.path.startsWith(prefix)` test in the extract filter.
|
|
3427
|
-
*/
|
|
3428
|
-
function normalizeLocation(loc) {
|
|
3429
|
-
return loc.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3430
|
-
}
|
|
3431
|
-
/**
|
|
3432
|
-
* Match an archive entry path against a location whitelist prefix.
|
|
3433
|
-
* `addons` matches `addons` (file) and `addons/foo/bar.txt` (path
|
|
3434
|
-
* inside the dir). The trailing `/` test is what prevents `addons`
|
|
3435
|
-
* from spuriously matching `addons-data`.
|
|
3436
|
-
*/
|
|
3437
|
-
function entryMatchesLocation(entryPath, prefix) {
|
|
3438
|
-
return entryPath === prefix || entryPath.startsWith(`${prefix}/`);
|
|
3439
|
-
}
|
|
3440
3626
|
//#endregion
|
|
3441
3627
|
//#region ../../node_modules/croner/dist/croner.js
|
|
3442
3628
|
function T(s) {
|
|
@@ -5713,12 +5899,22 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
5713
5899
|
const archiveId = (0, node_crypto.randomUUID)();
|
|
5714
5900
|
const archiveFilename = `${archiveId}.tar.gz`;
|
|
5715
5901
|
const cachePath = await this.stagingPath(archiveFilename);
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5902
|
+
let result;
|
|
5903
|
+
try {
|
|
5904
|
+
result = await this.systemBackup.createArchive({
|
|
5905
|
+
archivePath: cachePath,
|
|
5906
|
+
...input.locations ? { locations: [...input.locations] } : {},
|
|
5907
|
+
signal,
|
|
5908
|
+
onArchiveBytes: (bytes) => progress.setStagedBytes(bytes),
|
|
5909
|
+
owner: this.databaseOwner()
|
|
5910
|
+
});
|
|
5911
|
+
} catch (err) {
|
|
5912
|
+
if (err instanceof BackupSnapshotRefusedError) for (const dest of targets) {
|
|
5913
|
+
progress.destinationFailed(dest.id);
|
|
5914
|
+
await this.recordDestinationOutcome(dest.id, Date.now(), err.message);
|
|
5915
|
+
}
|
|
5916
|
+
throw err;
|
|
5917
|
+
}
|
|
5722
5918
|
progress.setArchiveSize(result.sizeBytes);
|
|
5723
5919
|
progress.setPhase("uploading");
|
|
5724
5920
|
try {
|
|
@@ -5799,6 +5995,25 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
5799
5995
|
}
|
|
5800
5996
|
}
|
|
5801
5997
|
/**
|
|
5998
|
+
* THIS node's database owner (D693), reached through `database-snapshot`
|
|
5999
|
+
* pinned to the local node: the paths it answers must be on this host,
|
|
6000
|
+
* because this process archives them. No local node id means no pin, and an
|
|
6001
|
+
* unpinned singleton call could be answered by another host — refuse.
|
|
6002
|
+
*/
|
|
6003
|
+
databaseOwner() {
|
|
6004
|
+
const pin = () => {
|
|
6005
|
+
const localNodeId = this.ctx.kernel.localNodeId;
|
|
6006
|
+
if (localNodeId === void 0 || localNodeId.length === 0) throw new Error("no local node id — the database owner cannot be pinned to this host");
|
|
6007
|
+
return require_dist.nodePin(require_dist.toNodeId(localNodeId));
|
|
6008
|
+
};
|
|
6009
|
+
return {
|
|
6010
|
+
listDatabases: async () => {
|
|
6011
|
+
return (await this.ctx.api.databaseSnapshot.listDatabases.query({}, pin())).databases.map((d) => d.livePath);
|
|
6012
|
+
},
|
|
6013
|
+
writeSnapshot: async () => this.ctx.api.databaseSnapshot.writeSnapshot.mutate({}, pin())
|
|
6014
|
+
};
|
|
6015
|
+
}
|
|
6016
|
+
/**
|
|
5802
6017
|
* Push the staged archive to a single destination via chunked
|
|
5803
6018
|
* upload, then update the per-location manifest. Applies the
|
|
5804
6019
|
* destination's retention policy as part of the same lock-protected
|