converge everything into one data folder

This commit is contained in:
legop3
2026-09-13 22:10:15 -04:00
parent b199f45eb2
commit 17b1404157
13 changed files with 860 additions and 40 deletions
+20 -8
View File
@@ -11,8 +11,6 @@ CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
@@ -30,6 +28,8 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
DATA_DIR="$SERVER_DIR/data"
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
@@ -281,10 +281,23 @@ rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
# The repository data directory is the legacy deployment's single persistence
# root and becomes the one bind-mounted /data directory during containerization.
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
# the other services already create their own children when those features run.
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
# Previous installers used these two /var/lib directories. Replay code already
# stopped reading its old location, and snapshots regenerate immediately, so do
# not merge possibly stale runtime media over the new canonical data tree. Keep
# an existing directory untouched and report it for deliberate cleanup after the
# operator verifies the upgraded server.
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
if [[ -d "$legacy_dir" ]]; then
echo " Legacy runtime directory is no longer used: $legacy_dir"
fi
done
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
@@ -297,8 +310,7 @@ Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
+10 -1
View File
@@ -5,7 +5,16 @@
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
# MediaMTX carries that environment into this runOnReady hook. Requiring that
# single root prevents the writer from silently recreating the former /var/lib
# snapshot store while the readers are looking inside the mounted data folder.
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
exit 1
fi
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
+30 -22
View File
@@ -1,41 +1,49 @@
// data Paths helper
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
const RUNTIME_DIR_NAME = 'runtime';
function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
/*
Always join through resolveDataDir instead of repeating environment handling
in individual services. This is what makes one SERVER_DATA_DIR mount contain
every database, JSON store, generated file, and persistent media directory.
*/
return path.join(resolveDataDir(), fileName);
}
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
function resolveRoverSnapshotDir() {
/*
Snapshot production, polling, PTZ reads, and health reporting must use the
exact same directory. Giving this shared directory a named resolver prevents
one of those consumers from drifting back to the former /var/lib location.
*/
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
}
function resolveRuntimePath(...pathSegments) {
/*
Disposable files are still files intentionally managed by the Node server.
Keeping them below a named runtime directory preserves the single-root
filesystem contract without confusing scratch files with durable stores.
Callers remain responsible for deleting their own completed work.
*/
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
}
module.exports = {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
};
+49
View File
@@ -0,0 +1,49 @@
// Data Paths Helper Tests
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
// Scope: Exercises path resolution only and never creates files in the real server data directory.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
} = require('./dataPaths');
const originalDataDir = process.env.SERVER_DATA_DIR;
test.afterEach(() => {
/*
Environment state is process-global. Restore the caller's value after each
assertion so this focused test remains safe when it is composed with other
tests in the same Node process later.
*/
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = originalDataDir;
});
test('defaults every persistent path to the canonical server data directory', () => {
delete process.env.SERVER_DATA_DIR;
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
assert.equal(resolveDataDir(), expectedRoot);
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
});
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
process.env.SERVER_DATA_DIR = configuredRoot;
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
assert.equal(
resolveRuntimePath('audio-forward', 'uploads'),
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
);
});
@@ -6,6 +6,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
@@ -25,7 +26,13 @@ const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
/*
FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
Node process from writing to an unrelated host temp directory and prevents a
configuration value from escaping the server's filesystem boundary.
*/
const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
+2 -1
View File
@@ -3,12 +3,13 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises');
const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
const { getReplayHealthSnapshot } = require('../replayEngineV2');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
@@ -5,7 +5,7 @@ const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
@@ -52,6 +52,16 @@ function createMediaMtxSupervisor(deps) {
logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
/*
MediaMTX passes its environment to runOnReady hooks. Supplying the
resolved value here also covers development starts where
SERVER_DATA_DIR was omitted, so the installed snapshot writer and every
Node snapshot reader still converge on the same canonical data root.
*/
env: {
...process.env,
SERVER_DATA_DIR: resolveDataDir(),
},
});
forwardLines(child.stdout, 'info');
@@ -0,0 +1,66 @@
// MediaMTX Supervisor Tests
// Purpose: Verifies that generated MediaMTX state and child hooks inherit the server's single data root.
// Scope: Uses a child-process double and a temporary directory; no listener or background process is started.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const EventEmitter = require('events');
const { PassThrough } = require('stream');
const { createMediaMtxSupervisor } = require('./supervisor');
test('passes the resolved data root to MediaMTX runOnReady hooks', () => {
const temporaryDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-mediamtx-supervisor-'));
const generatedConfigPath = path.join(temporaryDataDir, 'mediamtx.yml');
const previousDataDir = process.env.SERVER_DATA_DIR;
let invocation = null;
process.env.SERVER_DATA_DIR = temporaryDataDir;
const spawnProcess = (command, args, options) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = (signal) => {
child.emit('exit', 0, signal);
};
invocation = { command, args, options };
return child;
};
const logger = {
info() {},
warn() {},
error() {},
};
try {
const supervisor = createMediaMtxSupervisor({
config: { media: { additionalHosts: ['media.example.test'] } },
serverPort: 8080,
logger,
mediaMtxBin: '/test/bin/mediamtx',
snapshotWriterPath: '/test/bin/rover-snapshot-writer',
configPath: generatedConfigPath,
spawnProcess,
});
supervisor.start();
assert.equal(invocation.command, '/test/bin/mediamtx');
assert.deepEqual(invocation.args, [generatedConfigPath]);
assert.equal(invocation.options.env.SERVER_DATA_DIR, temporaryDataDir);
assert.equal(fs.existsSync(generatedConfigPath), true);
supervisor.stop();
} finally {
/*
Restore process-global state and delete only the test-owned directory so a
failed assertion cannot alter later tests or leave generated YAML behind.
*/
if (previousDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = previousDataDir;
fs.rmSync(temporaryDataDir, { recursive: true, force: true });
}
});
@@ -10,6 +10,7 @@ const { Cam } = require('onvif');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const {
shouldUseSnapshotsForNonTurnVideo,
@@ -43,7 +44,7 @@ const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
// explicitly disables replay for the camera.
const DEFAULT_REPLAY_ENABLED = true;
const DEFAULT_PTZ_COLOR = '#387bf8';
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
const SNAPSHOT_POLL_MS = 300;
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
@@ -1,8 +1,8 @@
// Replay Builder Pipeline
// Purpose: Assembles selected buffered segments into final replay output video with optional sidebar.
// Scope: Owns concat/probe/layout/transcode pipeline and returns replay buffer plus source usage metadata.
const os = require('os');
const path = require('path');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService');
const { getRecentMessages } = require('../chatService');
@@ -124,7 +124,15 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
durationMs: BUILD_DURATION_MS,
});
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
/*
A replay build is disposable, but every intermediate concat list, pinned
segment, and ffmpeg output is created on the server's behalf. Create a
unique workspace below SERVER_DATA_DIR so the application never spills
those writes into the host-wide temporary directory.
*/
const buildRoot = resolveRuntimePath('replay-builds');
await ensureDir(buildRoot);
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
try {
const usedSources = [];
@@ -4,11 +4,11 @@
const { execFile } = require('child_process');
const EventEmitter = require('events');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const logger = require('../../globals/logger').child('roomCameraReplay');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const execFileAsync = promisify(execFile);
@@ -118,7 +118,15 @@ async function buildRoomCameraReplayVideo({ cameraId = null } = {}, { getRoomCam
});
if (!cameraEntries.length) throw new Error('No camera frames available yet');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
/*
Hundreds of frame images can be produced for one room-camera replay. They
are temporary, but the Node application owns them, so both the workspace
and final intermediate video stay under the configured data root until the
existing finally block removes them.
*/
const buildRoot = resolveRuntimePath('room-camera-replay-builds');
await fsp.mkdir(buildRoot, { recursive: true });
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
try {
const firstFramePaths = [];
for (let i = 0; i < cameraEntries.length; i += 1) {
@@ -5,8 +5,9 @@ const EventEmitter = require('events');
const fs = require('fs/promises');
const path = require('path');
const logger = require('../../globals/logger').child('roverSnapshot');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
const POLL_INTERVAL_MS = 300;
const roverState = new Map();
const events = new EventEmitter();