mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
200 lines
8.3 KiB
JavaScript
200 lines
8.3 KiB
JavaScript
// Full Data Backup
|
|
// Purpose: Creates one validated archive of the durable server data without stopping live services or writers.
|
|
// Scope: Uses service-owned online database snapshots and stable file copies inside the canonical data directory.
|
|
const fs = require('fs');
|
|
const fsp = require('fs/promises');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const tar = require('tar');
|
|
const Database = require('better-sqlite3');
|
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
|
const packageInfo = require('../../../package.json');
|
|
|
|
const FORMAT_VERSION = 1;
|
|
const CONTROL_DIR_NAME = 'backup-restore';
|
|
const EXCLUDED_TOP_LEVEL_NAMES = new Set([CONTROL_DIR_NAME, 'runtime']);
|
|
const DATABASE_NAMES = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite'];
|
|
const DATABASE_FILES = new Set(DATABASE_NAMES.flatMap((name) => [name, `${name}-wal`, `${name}-shm`]));
|
|
const FILE_COPY_ATTEMPTS = 3;
|
|
|
|
async function removeSnapshotSidecars(payloadDir) {
|
|
/*
|
|
SQLite online backup produces a complete standalone main database file.
|
|
Reopening that snapshot to inspect its schema can still create empty WAL
|
|
and shared-memory coordination files because the database retains WAL as
|
|
its journal mode. Those files describe no durable backup content and must
|
|
be removed before the manifest inventory and tar archive are produced.
|
|
*/
|
|
await Promise.all(DATABASE_NAMES.flatMap((name) => [
|
|
fsp.rm(path.join(payloadDir, `${name}-wal`), { force: true }),
|
|
fsp.rm(path.join(payloadDir, `${name}-shm`), { force: true }),
|
|
]));
|
|
}
|
|
|
|
function readDatabaseSchemaVersions(payloadDir) {
|
|
const configuration = new Database(path.join(payloadDir, 'configuration.sqlite'), { readonly: true });
|
|
const identity = new Database(path.join(payloadDir, 'identity.sqlite'), { readonly: true });
|
|
const fleetReports = new Database(path.join(payloadDir, 'fleet-reports.sqlite'), { readonly: true });
|
|
try {
|
|
return {
|
|
configuration: Number(configuration.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version) || 0,
|
|
identity: Number(identity.pragma('user_version', { simple: true })) || 0,
|
|
// Fleet reporting currently evolves with additive startup checks and has
|
|
// no numbered migration table, so zero accurately identifies that scheme.
|
|
fleetReports: Number(fleetReports.pragma('user_version', { simple: true })) || 0,
|
|
};
|
|
} finally {
|
|
configuration.close();
|
|
identity.close();
|
|
fleetReports.close();
|
|
}
|
|
}
|
|
|
|
function sha256File(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = fs.createReadStream(filePath);
|
|
stream.on('error', reject);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
});
|
|
}
|
|
|
|
async function copyStableFile(sourcePath, destinationPath) {
|
|
for (let attempt = 0; attempt < FILE_COPY_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
const before = await fsp.stat(sourcePath);
|
|
if (!before.isFile()) throw new Error(`Backup source is not a regular file: ${sourcePath}`);
|
|
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
await fsp.copyFile(sourcePath, destinationPath);
|
|
await fsp.chmod(destinationPath, before.mode & 0o777);
|
|
|
|
/*
|
|
A writer may replace or append to a media file while it is copied. Size
|
|
and timestamp checks catch ordinary changes, while comparing hashes
|
|
catches a same-size replacement. Only the unstable file is retried;
|
|
no owning service is paused.
|
|
*/
|
|
const [sourceHash, destinationHash] = await Promise.all([
|
|
sha256File(sourcePath),
|
|
sha256File(destinationPath),
|
|
]);
|
|
// Read the final metadata only after both hashes finish. Starting this
|
|
// stat concurrently could miss a write that occurred during hashing.
|
|
const after = await fsp.stat(sourcePath);
|
|
if (before.size === after.size
|
|
&& before.mtimeMs === after.mtimeMs
|
|
&& sourceHash === destinationHash) return true;
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
// A rotating snapshot can disappear between directory enumeration and
|
|
// copying. Treat that exact race like any other unstable active file.
|
|
}
|
|
await fsp.rm(destinationPath, { force: true });
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
|
|
let entries;
|
|
try {
|
|
entries = await fsp.readdir(path.join(sourceDir, relativeDir), { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return [];
|
|
throw error;
|
|
}
|
|
|
|
const skipped = [];
|
|
for (const entry of entries) {
|
|
const relativePath = path.join(relativeDir, entry.name);
|
|
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
|
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
|
|
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
|
|
if (entry.isDirectory()) {
|
|
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) throw new Error(`Backup cannot include special file: ${relativePath}`);
|
|
const copied = await copyStableFile(
|
|
path.join(sourceDir, relativePath),
|
|
path.join(destinationDir, relativePath),
|
|
);
|
|
if (!copied) skipped.push(relativePath.split(path.sep).join('/'));
|
|
}
|
|
return skipped;
|
|
}
|
|
|
|
async function listManifestFiles(rootDir, relativeDir = '') {
|
|
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const relativePath = path.join(relativeDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...await listManifestFiles(rootDir, relativePath));
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) throw new Error(`Backup staging contains a special file: ${relativePath}`);
|
|
const filePath = path.join(rootDir, relativePath);
|
|
const stat = await fsp.stat(filePath);
|
|
files.push({
|
|
path: relativePath.split(path.sep).join('/'),
|
|
size: stat.size,
|
|
sha256: await sha256File(filePath),
|
|
});
|
|
}
|
|
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
}
|
|
|
|
async function createFullBackup({ configurationDatabase, identityService, fleetReportService, jobId }) {
|
|
const dataDir = resolveDataDir();
|
|
const jobDir = resolveDataPath(path.join(CONTROL_DIR_NAME, `backup-${jobId}`));
|
|
const payloadDir = path.join(jobDir, 'data');
|
|
const archivePath = path.join(jobDir, 'multirover-backup.tar.gz');
|
|
await fsp.rm(jobDir, { recursive: true, force: true });
|
|
await fsp.mkdir(payloadDir, { recursive: true });
|
|
|
|
try {
|
|
// Each database owner remains live and writes a coherent SQLite snapshot
|
|
// directly into the same staging tree as the ordinary durable files.
|
|
await Promise.all([
|
|
configurationDatabase.backupDatabase(path.join(payloadDir, DATABASE_NAMES[0])),
|
|
identityService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[1])),
|
|
fleetReportService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[2])),
|
|
]);
|
|
const skippedUnstableFiles = await copyDurableTree(dataDir, payloadDir);
|
|
/*
|
|
Finish every operation that can create a staged file before inventorying
|
|
the payload. Production databases use WAL mode, so schema inspection must
|
|
precede both sidecar cleanup and the final immutable file list.
|
|
*/
|
|
const databaseSchemaVersions = readDatabaseSchemaVersions(payloadDir);
|
|
await removeSnapshotSidecars(payloadDir);
|
|
const files = await listManifestFiles(payloadDir);
|
|
const manifest = {
|
|
format: 'multirover-full-backup',
|
|
formatVersion: FORMAT_VERSION,
|
|
applicationVersion: packageInfo.version,
|
|
createdAt: Date.now(),
|
|
databaseSchemaVersions,
|
|
files,
|
|
skippedUnstableFiles,
|
|
};
|
|
await fsp.writeFile(path.join(jobDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
await tar.c({ cwd: jobDir, file: archivePath, gzip: true, portable: true }, ['manifest.json', 'data']);
|
|
return { archivePath, jobDir, manifest };
|
|
} catch (error) {
|
|
await fsp.rm(jobDir, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
CONTROL_DIR_NAME,
|
|
DATABASE_NAMES,
|
|
FORMAT_VERSION,
|
|
createFullBackup,
|
|
readDatabaseSchemaVersions,
|
|
removeSnapshotSidecars,
|
|
sha256File,
|
|
};
|